Coverage for scanpath_studio/authoring_component.py: 100%
14 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-07 21:10 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-07 21:10 +0000
1"""Bidirectional browser canvas for the VIZ-33 authoring document."""
3from __future__ import annotations
5from typing import Any
7import pandas as pd
8import streamlit as st
10_HTML = """
11<div class="authoring-toolbar">
12 <span>Click outside the words to add · drag to move · click to select</span>
13 <button type="button" class="delete" disabled>Delete selected</button>
14</div>
15<svg class="authoring-canvas" role="img" aria-label="Interactive fixation authoring canvas"></svg>
16"""
18_CSS = """
19:host { display: block; color: var(--st-text-color); font-family: var(--st-font); }
20.authoring-toolbar { display: flex; align-items: center; justify-content: space-between;
21 gap: .75rem; margin: 0 0 .4rem; font-size: .82rem; color: var(--st-text-color); }
22.delete { border: 1px solid color-mix(in srgb, var(--st-text-color) 25%, transparent);
23 border-radius: .4rem; padding: .25rem .55rem; color: var(--st-text-color);
24 background: var(--st-secondary-background-color); cursor: pointer; }
25.delete:disabled { opacity: .45; cursor: default; }
26.authoring-canvas { display: block; width: 100%; max-height: 580px; min-height: 240px;
27 border: 1px solid color-mix(in srgb, var(--st-text-color) 22%, transparent);
28 border-radius: .45rem; background: white; touch-action: none; user-select: none; }
29.word-box { fill: #f7f8fa; stroke: #b7bec9; stroke-width: 1; }
30.word-label { fill: #222; font: 16px sans-serif; dominant-baseline: middle;
31 pointer-events: none; }
32.fixation { fill: #2f6fed; fill-opacity: .78; stroke: white; stroke-width: 3;
33 cursor: grab; }
34.fixation.selected { fill: #f59e0b; stroke: #111827; stroke-width: 4; }
35.fixation:focus { outline: none; stroke: #111827; stroke-width: 5; }
36.order { fill: white; font: bold 12px sans-serif; dominant-baseline: middle;
37 text-anchor: middle; pointer-events: none; }
38"""
40_JS = r"""
41export default function(component) {
42 const { data, parentElement, setTriggerValue } = component;
43 const svg = parentElement.querySelector('.authoring-canvas');
44 const deleteButton = parentElement.querySelector('.delete');
45 const ns = 'http://www.w3.org/2000/svg';
46 const width = Number(data.canvas_width || 1200);
47 const height = Number(data.canvas_height || 700);
48 const selected = data.selected_fixation_id == null ? null : Number(data.selected_fixation_id);
49 // Streamlit v2 reuses the component host across reruns. Rebuild the SVG
50 // rather than appending another copy of every word/fixation and stacking a
51 // second canvas-level pointer handler.
52 svg.replaceChildren();
53 svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
54 svg.setAttribute('preserveAspectRatio', 'xMidYMin meet');
55 svg.style.aspectRatio = `${width} / ${height}`;
57 const make = (tag, attrs, text) => {
58 const element = document.createElementNS(ns, tag);
59 Object.entries(attrs || {}).forEach(([key, value]) => element.setAttribute(key, String(value)));
60 if (text != null) element.textContent = String(text);
61 svg.appendChild(element);
62 return element;
63 };
64 const point = (event) => {
65 const value = svg.createSVGPoint();
66 value.x = event.clientX;
67 value.y = event.clientY;
68 const local = value.matrixTransform(svg.getScreenCTM().inverse());
69 return { x: Math.round(local.x * 10) / 10, y: Math.round(local.y * 10) / 10 };
70 };
72 (data.words || []).forEach((word) => {
73 make('rect', { class: 'word-box', x: word.x, y: word.y, width: word.width, height: word.height });
74 make('text', { class: 'word-label', x: Number(word.x) + 3,
75 y: Number(word.y) + Number(word.height) / 2 }, word.text);
76 });
78 let dragged = null;
79 const finishDrag = (pointerEvent) => {
80 if (!dragged) return false;
81 const next = point(pointerEvent);
82 setTriggerValue('event', dragged.moved
83 ? { type: 'move', fixation_id: dragged.id, x: next.x, y: next.y }
84 : { type: 'select', fixation_id: dragged.id });
85 dragged = null;
86 return true;
87 };
88 (data.events || []).forEach((event) => {
89 const id = Number(event.fixation_id);
90 const circle = make('circle', {
91 class: `fixation${id === selected ? ' selected' : ''}`,
92 cx: event.x, cy: event.y, r: 18, tabindex: 0,
93 'aria-label': `Fixation ${id}, order ${event.order_in_trial}`,
94 'data-fixation-id': id,
95 });
96 make('text', { class: 'order', x: event.x, y: event.y }, event.order_in_trial);
97 circle.addEventListener('pointerdown', (pointerEvent) => {
98 pointerEvent.stopPropagation();
99 circle.setPointerCapture(pointerEvent.pointerId);
100 dragged = { id, circle, start: point(pointerEvent), moved: false };
101 });
102 circle.addEventListener('pointermove', (pointerEvent) => {
103 if (!dragged || dragged.id !== id) return;
104 const next = point(pointerEvent);
105 if (Math.hypot(next.x - dragged.start.x, next.y - dragged.start.y) > 2) {
106 dragged.moved = true;
107 }
108 circle.setAttribute('cx', next.x);
109 circle.setAttribute('cy', next.y);
110 });
111 circle.addEventListener('pointerup', (pointerEvent) => {
112 if (!dragged || dragged.id !== id) return;
113 finishDrag(pointerEvent);
114 });
115 circle.addEventListener('keydown', (keyEvent) => {
116 if (keyEvent.key === 'Delete' || keyEvent.key === 'Backspace') {
117 keyEvent.preventDefault();
118 setTriggerValue('event', { type: 'delete', fixation_id: id });
119 }
120 });
121 });
123 svg.onpointerup = (pointerEvent) => {
124 // Pointer capture is not guaranteed in every embedded browser. A release
125 // that lands elsewhere on the SVG must still commit the drag instead of
126 // leaving only a transient local marker move.
127 if (finishDrag(pointerEvent)) return;
128 if (pointerEvent.target !== svg) return;
129 const next = point(pointerEvent);
130 setTriggerValue('event', { type: 'add', x: next.x, y: next.y });
131 };
132 deleteButton.disabled = selected == null;
133 deleteButton.onclick = () => {
134 if (selected != null) setTriggerValue('event', { type: 'delete', fixation_id: selected });
135 };
136}
137"""
140def _spatial_editor() -> Any:
141 """Register once per script run (the v2 registry is run-scoped in AppTest)."""
142 return st.components.v2.component(
143 "scanpath_authoring_canvas",
144 html=_HTML,
145 css=_CSS,
146 js=_JS,
147 )
150def render_authoring_canvas(
151 words: pd.DataFrame,
152 events: pd.DataFrame,
153 *,
154 canvas_width: int,
155 canvas_height: int,
156 selected_fixation_id: int | None,
157) -> Any:
158 """Mount the editor and return its compact ``result.event`` trigger value."""
159 word_fields = ["word_id", "text", "line_idx", "x", "y", "width", "height"]
160 event_fields = ["fixation_id", "order_in_trial", "x", "y", "duration_ms"]
161 result = _spatial_editor()(
162 key="authoring_spatial_editor",
163 data={
164 "words": words[word_fields].to_dict("records") if not words.empty else [],
165 "events": events[event_fields].to_dict("records")
166 if not events.empty
167 else [],
168 "canvas_width": canvas_width,
169 "canvas_height": canvas_height,
170 "selected_fixation_id": selected_fixation_id,
171 },
172 on_event_change=lambda: None,
173 height="content",
174 )
175 return getattr(result, "event", None)