refactor: reuse luggage tags for thread labels

This commit is contained in:
2026-08-14 17:02:17 +02:00
parent a4b22a574b
commit ccef0955d4
4 changed files with 31 additions and 13 deletions
+4 -3
View File
@@ -422,6 +422,7 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
const touchPoints = useRef(new Map<number, { x: number; y: number }>())
const pinchDistance = useRef<number | null>(null)
const [threadPointer, setThreadPointer] = useState<{ x: number; y: number } | null>(null)
const [expandedThreadTagId, setExpandedThreadTagId] = useState<string | null>(null)
const byId = useMemo(() => new Map(state.evidence.map(e => [e.id, e])), [state.evidence])
const containmentRelations = state.relations.filter(relation => relation.type === 'contains')
const pointForId = (id: string) => {
@@ -508,7 +509,7 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
const toggleFolder = (id: string) => update(s => ({ ...s, evidence: s.evidence.map(widget => widget.id === id ? { ...widget, config: { ...(widget.config || {}), open: !folderIsOpen(widget) } } : widget) }))
const previewOrigin = linkFrom ? pointForId(linkFrom) : undefined
return <div className={`board-viewport tool-${tool} ${linkFrom ? 'threading' : ''}`} ref={boardRef}
onPointerDown={e => { if (e.pointerType === 'touch' || tool === 'hand' || e.button === 1) { e.preventDefault(); pointerDown(e) } }}
onPointerDown={e => { if (!(e.target as HTMLElement).closest('.thread-tag')) setExpandedThreadTagId(null); if (e.pointerType === 'touch' || tool === 'hand' || e.button === 1) { e.preventDefault(); pointerDown(e) } }}
onPointerMoveCapture={trackThreadPointer} onPointerMove={pointerMove} onPointerLeave={() => setThreadPointer(null)} onPointerUp={finishDrag} onPointerCancel={finishDrag} onAuxClick={e => { if (e.button === 1) e.preventDefault() }}>
<div className="board" style={{ width: BOARD_W, height: BOARD_H, transform: `translate(${state.viewport.x}px, ${state.viewport.y}px) scale(${state.viewport.zoom})` }}>
<div className="board-stamp">AUTHORIZED CITIZEN SCIENTIST WORKSTATION <span>GU-NET / 04</span></div>
@@ -516,7 +517,7 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
{state.connections.map(connection => { const p1 = pointForId(connection.fromEvidenceId), p2 = pointForId(connection.toEvidenceId); if (!p1 || !p2) return null; return <g className={recentlyCreatedConnectionId === connection.id ? 'tightening' : ''} key={connection.id}><path d={threadCurve(p1, p2, connection.tightness).path}/><circle cx={p1.x} cy={p1.y} r="4"/><circle cx={p2.x} cy={p2.y} r="4"/></g> })}
{previewOrigin && threadPointer && <g className="thread-preview"><path d={threadCurve(previewOrigin, threadPointer, 35).path}/><circle cx={previewOrigin.x} cy={previewOrigin.y} r="4"/><circle cx={threadPointer.x} cy={threadPointer.y} r="3"/></g>}
</svg>
{state.connections.map(connection => { const p1 = pointForId(connection.fromEvidenceId), p2 = pointForId(connection.toEvidenceId); if (!p1 || !p2) return null; const midpoint = threadCurve(p1, p2, connection.tightness).midpoint; return <button key={`tag:${connection.id}`} className={`thread-tag ${connection.label ? 'labelled' : ''}`} style={{ left: midpoint.x, top: midpoint.y }} title="Edit thread tag and tightness" onPointerDown={event => event.stopPropagation()} onClick={event => { event.stopPropagation(); onEditConnection(connection) }}><i/>{connection.label && <span>{connection.label}</span>}</button> })}
{state.connections.map(connection => { const p1 = pointForId(connection.fromEvidenceId), p2 = pointForId(connection.toEvidenceId); if (!p1 || !p2) return null; const midpoint = threadCurve(p1, p2, connection.tightness).midpoint; const expanded = expandedThreadTagId === connection.id; return <button key={`tag:${connection.id}`} aria-expanded={connection.label ? expanded : undefined} aria-label={connection.label ? `Relation tag: ${connection.label}` : 'Edit untagged red thread'} className={`thread-tag ${connection.label ? 'labelled luggage-tag' : 'untagged'} ${expanded ? 'expanded' : ''}`} style={{ left: midpoint.x, top: midpoint.y }} title={connection.label ? expanded ? 'Click again to edit this thread' : 'Rotate relation tag to read' : 'Edit thread tag and tightness'} onPointerDown={event => event.stopPropagation()} onClick={event => { event.stopPropagation(); if (!connection.label || expanded) onEditConnection(connection); else setExpandedThreadTagId(connection.id) }}><i/>{connection.label && <span className="thread-tag-content"><small>RELATION TAG</small><b>{connection.label}</b>{expanded && <em>CLICK AGAIN TO EDIT THREAD</em>}</span>}</button> })}
<svg className="event-support-lines" width={BOARD_W} height={BOARD_H}>
{state.evidence.filter(event => event.type === 'event').flatMap(event => (event.supportingEvidenceIds || []).flatMap(evidenceId => {
const evidence = byId.get(evidenceId)
@@ -548,7 +549,7 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
<svg className="folder-bands" width={BOARD_W} height={BOARD_H}>
{containmentRelations.map(relation => { const folder = byId.get(relation.fromWidgetId), document = state.documents.find(candidate => candidate.id === relation.toWidgetId); if (!folder || !document) return null; const open = folderIsOpen(folder), position = relationPosition(state, relation), origin = { x: folder.x + folder.width / 2, y: folder.y + 78 }; return <line className={open ? 'open' : 'closed'} key={relation.id} x1={origin.x} y1={origin.y} x2={open ? position.x + 87 : origin.x} y2={open ? position.y + 72 : origin.y}/> })}
</svg>
{state.evidence.map((ev, i) => { const containedDocuments = containedIds(state, ev.id).flatMap(id => { const document = state.documents.find(candidate => candidate.id === id); return document ? [document] : [] }); const definition = exhibitWidget(ev.type); const Widget = definition.Component; return <article key={ev.id} data-temporal-id={`widget:${ev.id}`} className={`evidence-card ${definition.visualType} ${selected === ev.id ? 'selected' : ''} ${linkFrom === ev.id ? 'linking' : ''} ${linkFrom && linkFrom !== ev.id ? 'thread-target' : ''} ${recentlyCreatedExhibitId === ev.id ? 'arriving' : ''}`} style={{ left: ev.x, top: ev.y, width: ev.width, rotate: `${(i % 3 - 1) * .45}deg` }}
{state.evidence.map((ev, i) => { const containedDocuments = containedIds(state, ev.id).flatMap(id => { const document = state.documents.find(candidate => candidate.id === id); return document ? [document] : [] }); const definition = exhibitWidget(ev.type); const Widget = definition.Component; return <article key={ev.id} data-temporal-id={`widget:${ev.id}`} className={`evidence-card ${definition.visualType} ${ev.type === 'note' ? 'luggage-tag' : ''} ${selected === ev.id ? 'selected' : ''} ${linkFrom === ev.id ? 'linking' : ''} ${linkFrom && linkFrom !== ev.id ? 'thread-target' : ''} ${recentlyCreatedExhibitId === ev.id ? 'arriving' : ''}`} style={{ left: ev.x, top: ev.y, width: ev.width, rotate: `${(i % 3 - 1) * .45}deg` }}
onPointerDown={e => { e.stopPropagation(); if (linkFrom && e.button === 0) return; if (tool === 'hand' || e.button === 1) { e.preventDefault(); pointerDown(e) } else if (e.button === 0) pointerDown(e, { kind: 'widget', id: ev.id }) }}
onPointerMove={e => { e.stopPropagation(); pointerMove(e) }} onPointerUp={e => { e.stopPropagation(); finishDrag(e) }} onPointerCancel={e => { e.stopPropagation(); finishDrag(e) }}
onAuxClick={e => { if (e.button === 1) e.preventDefault() }} onClick={e => { e.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (tool === 'move') onCardClick(ev.id) }}>