diff --git a/cortex/tests/test_webgl_tour.py b/cortex/tests/test_webgl_tour.py new file mode 100644 index 00000000..5ccf2cbe --- /dev/null +++ b/cortex/tests/test_webgl_tour.py @@ -0,0 +1,31 @@ +"""Tests for the optional guided-tour feature (cortex.webgl.make_static(tour=True)).""" +import cortex.webgl # noqa: F401 (ensures cortex.webgl is importable) +from cortex.webgl import serve +from cortex.webgl.FallbackLoader import FallbackLoader + + +def _render_base_template(tour): + """Render the base webgl template with the given `tour` flag (no subject needed).""" + loader = FallbackLoader([serve.cwd]) + tpl = loader.load("template.html") + html = tpl.generate( + title="test", + leapmotion=False, + python_interface=False, + tour=tour, + colormaps=[("RdBu_r", "colormaps/RdBu_r.png")], + default_cmap="RdBu_r", + ) + return html.decode("utf-8") if isinstance(html, bytes) else html + + +def test_tour_include_present_when_enabled(): + html = _render_base_template(True) + assert "resources/js/tour.js" in html + assert "resources/css/tour.css" in html + + +def test_tour_include_absent_by_default(): + html = _render_base_template(False) + assert "resources/js/tour.js" not in html + assert "resources/css/tour.css" not in html diff --git a/cortex/webgl/resources/css/tour.css b/cortex/webgl/resources/css/tour.css new file mode 100644 index 00000000..af32b041 --- /dev/null +++ b/cortex/webgl/resources/css/tour.css @@ -0,0 +1,35 @@ +/* tour.css — styles for the optional guided-tour box (see resources/js/tour.js). + Adapted from the gallantlab semantic viewers (after the NYT visualizer). */ +div#tourbox { + font-family: Helvetica, Arial, sans-serif; + color: white; + width: 25%; + left: 0; + min-width: 600px; + z-index: 100; + visibility: visible; + position: absolute; + top: 0; + opacity: 0.95; + background-color: rgba(0,0,0,0.7); + padding: 0 10px 10px 10px; +} +div.tour-stepper { margin-bottom: 10px; } +div.tour-steps { margin-bottom: 5px; display: inline-block; } +div.tour-step { + display: inline-block; width: 6px; height: 6px; margin-right: 10px; + border-radius: 4px; border: 1px solid white; cursor: pointer; + background: none; transition: background 0.5s; +} +div.tour-steps .active { background: white; } +div.tour-hideshow { display: inline-block; font-size: 8pt; cursor: pointer; } +div.tour-button { + border: 1px solid white; color: #cccccc; width: 100px; padding: 10px 0; + display: inline-block; background: none; text-align: center; opacity: 0.6; + transition: background 0.5s; +} +div.tour-button.active { opacity: 1.0; cursor: pointer; } +div.tour-back { border-top-left-radius: 5px; border-bottom-left-radius: 5px; } +div.tour-next { border-top-right-radius: 5px; border-bottom-right-radius: 5px; } +div.tour-title { font-size: 18pt; margin-bottom: 10px; } +div.tour-content { font-size: 12pt; font-family: Helvetica, Arial, sans-serif; } diff --git a/cortex/webgl/resources/js/mriview.js b/cortex/webgl/resources/js/mriview.js index f5aacfc5..436079ed 100644 --- a/cortex/webgl/resources/js/mriview.js +++ b/cortex/webgl/resources/js/mriview.js @@ -1310,6 +1310,14 @@ var mriview = (function(module) { hide_labels: {action:hidelabels, key:'l', hidden:true, help:'Toggle labels'}, }); + // optional guided tour (cortex.webgl.make_static(tour=True)) + if (typeof viewopts !== "undefined" && viewopts.tour && typeof Tour !== "undefined") { + this.tour = new Tour(this); + this.ui.add({ + toggle_tour: {action: this.tour.toggle.bind(this.tour), key: 'o', hidden: true, help: 'Toggle tour'}, + }); + } + //add sliceplane gui var sliceplane_ui = this.ui.addFolder("sliceplanes", true) sliceplane_ui.add({ diff --git a/cortex/webgl/resources/js/tour.js b/cortex/webgl/resources/js/tour.js new file mode 100644 index 00000000..331bf716 --- /dev/null +++ b/cortex/webgl/resources/js/tour.js @@ -0,0 +1,113 @@ +/* tour.js — optional guided-tour stepper for pycortex webgl viewers. + * + * Enabled by cortex.webgl.make_static(..., tour=True). Ships a generic stub + * tour (no dataset-specific text); mriview.js constructs it on boot when + * viewopts.tour is set and binds the 'o' key to Tour.toggle(). + * + * Adapted from the tour used in the gallantlab semantic viewers (itself after + * the NYT visualizer by Gregor Aisch & Amanda Cox). Cleaned for reuse: it + * self-injects its markup, per-step `view`/`call` hooks are optional, and its + * keyboard show/hide acts on the whole box (the old showhide left a stray strip). + */ +(function () { + "use strict"; + + // self-contained tourbox markup (no template HTML needed) + var TOURBOX_HTML = + '
' + + '
' + + '
(hide tour)

' + + '
Back
' + + '
Next
' + + '
' + + '
' + + '
'; + + // generic stub steps — nothing dataset-specific (f/i/r/h/o are real core keys) + var STEPS = [ + {title: "Welcome", + content: "

This is a short guided tour of the viewer. Use Back / Next or the dots above to step through it. Press o to hide or show this box.

"}, + {title: "Navigating", + content: "

Drag to rotate the brain, scroll to zoom, and Shift+drag to pan.

"}, + {title: "The surface", + content: "

Press f to flatten the cortex, i to inflate it, and r to reset the view.

"}, + {title: "Explore", + content: "

Press h at any time to see every keyboard shortcut. That's it — go explore!

"} + ]; + + // Tour(viewer, content?) — content defaults to the generic stub STEPS. + var Tour = function (viewer, content) { + this.viewer = viewer; + this.content = content || STEPS; + this.hidden = false; + this.object = document.createElement("div"); + this.object.id = "tourbox"; + this.object.innerHTML = TOURBOX_HTML; + document.body.appendChild(this.object); + this.setup(); + }; + + Tour.prototype.setup = function () { + var self = this; + $(this.object).find(".tour-hideshow").click(this.showhide.bind(this)); + this.content.forEach(function (step, i) { + var el = document.createElement("div"); + $(el).addClass("tour-step").attr("title", step.title) + .click(function () { self.goto_step(i); }); + $(self.object).find(".tour-steps").append(el); + }); + this.goto_step(0); + }; + + Tour.prototype.goto_step = function (idx) { + this.current_step = parseInt(idx, 10); + this.update_buttons(); + var steps = $(this.object).find(".tour-step"); + steps.removeClass("active"); + $(steps[this.current_step]).addClass("active"); + var cont = this.content[this.current_step]; + $(this.object).find(".tour-title").html(cont.title); + $(this.object).find(".tour-content").html(cont.content); + // per-step camera move / callback are optional (the stub has neither) + if (cont.view && this.viewer && this.viewer.animate) this.viewer.animate(cont.view); + if (typeof cont.call === "function") cont.call(this.viewer); + }; + + Tour.prototype.update_buttons = function () { + var self = this; + var last_idx = this.current_step - 1; + var next_idx = this.current_step + 1; + var back = $(this.object).find(".tour-back"); + var next = $(this.object).find(".tour-next"); + back.unbind("click"); + if (this.current_step === 0) { + back.removeClass("active"); + } else { + back.addClass("active").click(function () { self.goto_step(last_idx); }); + } + if (this.current_step === this.content.length - 1) next_idx = 0; + next.unbind("click").click(function () { self.goto_step(next_idx); }); + }; + + // on-screen link: collapse to / expand from the stepper (keeps the affordance) + Tour.prototype.showhide = function () { + var box = $(this.object); + if (this.hidden) { + box.find(".tour-button, .tour-title, .tour-content").show(); + box.find(".tour-hideshow").html("(hide tour)"); + this.hidden = false; + } else { + box.find(".tour-button, .tour-title, .tour-content").hide(); + box.find(".tour-hideshow").html("(show tour)"); + this.hidden = true; + } + }; + + // keyboard 'o': fully show/hide the whole box (nothing left behind — the fix) + Tour.prototype.toggle = function () { + var el = this.object; + el.style.display = (el.style.display === "none" ? "" : "none"); + }; + + window.Tour = Tour; +})(); diff --git a/cortex/webgl/template.html b/cortex/webgl/template.html index bfabfb15..bf336430 100644 --- a/cortex/webgl/template.html +++ b/cortex/webgl/template.html @@ -46,6 +46,11 @@ {% if python_interface %} {% end %} + +{% if tour %} + + +{% end %} {% block javascripts %} {% end %} diff --git a/cortex/webgl/view.py b/cortex/webgl/view.py index d32ad791..c686d8c1 100644 --- a/cortex/webgl/view.py +++ b/cortex/webgl/view.py @@ -55,6 +55,7 @@ def make_static( html_embed=True, copy_ctmfiles=True, title="Brain", + tour=False, layout=None, overlay_file=None, curvature_brightness=None, @@ -113,6 +114,10 @@ def make_static( title : str, optional The title that is displayed on the viewer website when it is loaded in a browser. + tour : bool, optional + If True, bake in pycortex's built-in generic guided tour: a small, + dataset-agnostic stepper the user can page through, collapse with its + on-screen link, or fully toggle with the 'o' key. Default False. layout : None or list of (int, int) The layout of the viewer subwindows for showing multiple subjects, passed to the template generator. @@ -262,6 +267,7 @@ def make_static( for sec in options.config.sections(): if "paths" in sec or "labels" in sec: my_viewopts[sec] = dict(options.config.items(sec)) + my_viewopts["tour"] = bool(tour) html = tpl.generate( data=json.dumps(metadata), @@ -269,6 +275,7 @@ def make_static( default_cmap="RdBu_r", python_interface=False, leapmotion=True, + tour=bool(tour), layout=layout, subjects=json.dumps(ctms), viewopts=json.dumps(my_viewopts),