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

1"""Bidirectional browser canvas for the VIZ-33 authoring document.""" 

2 

3from __future__ import annotations 

4 

5from typing import Any 

6 

7import pandas as pd 

8import streamlit as st 

9 

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""" 

17 

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""" 

39 

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}`; 

56 

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 }; 

71 

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 }); 

77 

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 }); 

122 

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""" 

138 

139 

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 ) 

148 

149 

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)