From b39d2efa75d91e29d1a08c9537466ae8449836ce Mon Sep 17 00:00:00 2001 From: jenstandstad Date: Fri, 14 Aug 2026 18:26:20 +0200 Subject: [PATCH] feat: drag relation tags along threads --- e2e/mystery.acceptance.spec.ts | 22 +++++++- migrations/011_connection_tag_position.sql | 8 +++ server/api.integration.test.ts | 6 +-- server/boardClone.ts | 8 +-- server/levelRepository.ts | 14 +++-- server/migrations.integration.test.ts | 8 +-- src/App.tsx | 39 +++++++++++--- src/boardDomain.test.ts | 15 ++++++ src/boardDomain.ts | 63 ++++++++++++++++++++-- src/styles.css | 6 ++- src/types.ts | 4 ++ 11 files changed, 164 insertions(+), 29 deletions(-) create mode 100644 migrations/011_connection_tag_position.sql diff --git a/e2e/mystery.acceptance.spec.ts b/e2e/mystery.acceptance.spec.ts index 88f4f34..ecaa9b2 100644 --- a/e2e/mystery.acceptance.spec.ts +++ b/e2e/mystery.acceptance.spec.ts @@ -1,5 +1,15 @@ import { expect, test, type Page } from '@playwright/test' +async function dragBy(page: Page, locator: ReturnType, deltaX: number, deltaY: number) { + const box = await locator.boundingBox() + if (!box) throw new Error('Drag target is not visible') + const start = { x: box.x + box.width / 2, y: box.y + Math.min(18, box.height / 4) } + await page.mouse.move(start.x, start.y) + await page.mouse.down() + await page.mouse.move(start.x + deltaX, start.y + deltaY, { steps: 8 }) + await page.mouse.up() +} + async function waitForSave(page: Page, action: () => Promise) { const response = page.waitForResponse(candidate => candidate.request().method() === 'PUT' && candidate.url().includes('/api/levels/') && candidate.ok()) await action() @@ -130,11 +140,18 @@ test('a cloned Glass Harbor level can be solved without modifying its template', await expect(page.locator('.connections path')).toHaveCount(3) const relationTag = page.locator('.thread-tag').filter({ hasText: 'Proof Elias is the driver' }) await expect(relationTag).toContainText('Proof Elias is the driver') + const tagBefore = await relationTag.boundingBox() + await waitForSave(page, () => dragBy(page, relationTag, 75, -18)) + const tagAfter = await relationTag.boundingBox() + expect(tagBefore).not.toBeNull() + expect(tagAfter).not.toBeNull() + expect(Math.abs(tagAfter!.x - tagBefore!.x) + Math.abs(tagAfter!.y - tagBefore!.y)).toBeGreaterThan(20) await relationTag.click() await expect(relationTag).toHaveClass(/\bexpanded\b/) await expect(relationTag).toHaveAttribute('aria-expanded', 'true') await relationTag.click() await expect(page.getByText('Edit red thread')).toBeVisible() + expect(await page.getByRole('slider', { name: 'Tag position', exact: true }).inputValue()).not.toBe('50') await page.locator('.tag-style-picker input[value="compact"]').check() await waitForSave(page, () => page.getByRole('button', { name: 'SAVE THREAD', exact: true }).click()) await expect(relationTag).toHaveClass(/\bcompact\b/) @@ -144,7 +161,10 @@ test('a cloned Glass Harbor level can be solved without modifying its template', await page.getByRole('button', { name: 'Close thread editor', exact: true }).click() const solved = await (await request.get(`/api/levels/${playable.id}`)).json() - expect(solved.connections).toContainEqual(expect.objectContaining({ label: 'Proof Elias is the driver', tightness: 85, tagStyle: 'compact' })) + const eliasThread = solved.connections.find((connection: { label?: string }) => connection.label === 'Proof Elias is the driver') + expect(eliasThread).toEqual(expect.objectContaining({ tightness: 85, tagStyle: 'compact', tagPosition: expect.any(Number), tagOffset: expect.any(Number) })) + expect(eliasThread.tagPosition).not.toBe(50) + expect(Math.abs(eliasThread.tagOffset)).toBeLessThanOrEqual(19) expect(solved.connections.filter((connection: { label?: string }) => connection.label === 'Proves Voss owns Warehouse 3')).toHaveLength(2) const template = await (await request.get('/api/templates')).json() as { slug: string; currentVersion: number }[] diff --git a/migrations/011_connection_tag_position.sql b/migrations/011_connection_tag_position.sql new file mode 100644 index 0000000..87ac91d --- /dev/null +++ b/migrations/011_connection_tag_position.sql @@ -0,0 +1,8 @@ +ALTER TABLE osint.exhibit_connections + ADD COLUMN tag_position_percent SMALLINT NOT NULL DEFAULT 50 + CHECK (tag_position_percent BETWEEN 0 AND 100), + ADD COLUMN tag_lateral_offset SMALLINT NOT NULL DEFAULT 0 + CHECK (ABS(tag_lateral_offset) <= 10 + ROUND((100 - tightness) * 0.6)); + +COMMENT ON COLUMN osint.exhibit_connections.tag_position_percent IS 'Position of the relation tag along its red thread, measured from the source exhibit'; +COMMENT ON COLUMN osint.exhibit_connections.tag_lateral_offset IS 'Signed perpendicular tag displacement, limited by thread tightness'; diff --git a/server/api.integration.test.ts b/server/api.integration.test.ts index d9a2fd7..761a511 100644 --- a/server/api.integration.test.ts +++ b/server/api.integration.test.ts @@ -86,7 +86,7 @@ suite('level persistence API', () => { { id: eventId, type: 'event', title: 'The meeting occurred', content: 'The evidence places the meeting on 18 April.', eventDate: '2021-04-18T14:30:00Z', supportingEvidenceIds: [documentId, noteId], x: 520, y: 610, width: 270 }, ] state.relations = [{ id: `contains:${folderId}:${documentId}`, fromWidgetId: folderId, toWidgetId: documentId, type: 'contains', sortOrder: 0, config: { x: 1051, y: 417 } }] - state.connections = [{ id: randomUUID(), fromEvidenceId: folderId, toEvidenceId: documentId, label: 'Primary source', tightness: 85, tagStyle: 'compact' }] + state.connections = [{ id: randomUUID(), fromEvidenceId: folderId, toEvidenceId: documentId, label: 'Primary source', tightness: 85, tagStyle: 'compact', tagPosition: 72, tagOffset: -12 }] const saveResponse = await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`, { method: 'PUT', @@ -100,7 +100,7 @@ suite('level persistence API', () => { expect(loaded.timelineRange).toEqual(state.timelineRange) expect(loaded.evidence[0]).toMatchObject({ id: folderId, x: 685, y: 417, config: { open: true } }) expect(loaded.relations[0]).toMatchObject({ id: `contains:${folderId}:${documentId}`, config: { x: 1051, y: 417 } }) - expect(loaded.connections).toContainEqual(expect.objectContaining({ fromEvidenceId: folderId, toEvidenceId: documentId, label: 'Primary source', tightness: 85, tagStyle: 'compact' })) + expect(loaded.connections).toContainEqual(expect.objectContaining({ fromEvidenceId: folderId, toEvidenceId: documentId, label: 'Primary source', tightness: 85, tagStyle: 'compact', tagPosition: 72, tagOffset: -12 })) expect(loaded.brief.concepts).toEqual(expect.arrayContaining([ expect.objectContaining({ label: 'Ada Lovelace', expectedPartyKind: 'person' }), expect.objectContaining({ label: 'Analytical Engines Ltd', expectedPartyKind: 'organization' }), @@ -217,7 +217,7 @@ suite('level persistence API', () => { expect(clone.documents[0].id).not.toBe(savedPlayerState.documents[0].id) expect(clone.evidence.map(item => item.id)).not.toContain(folderId) expect(clone.connections).toHaveLength(1) - expect(clone.connections[0]).toMatchObject({ label: 'Primary source', tightness: 85, tagStyle: 'compact', toEvidenceId: clone.documents[0].id }) + expect(clone.connections[0]).toMatchObject({ label: 'Primary source', tightness: 85, tagStyle: 'compact', tagPosition: 72, tagOffset: -12, toEvidenceId: clone.documents[0].id }) expect(clone.evidence.find(item => item.type === 'note')).toMatchObject({ sourceRegionId: 'stamp' }) const clonedEvent = clone.evidence.find(item => item.type === 'event')! expect(clonedEvent).toMatchObject({ title: 'The meeting occurred', eventDate: '2021-04-18T14:30:00.000Z' }) diff --git a/server/boardClone.ts b/server/boardClone.ts index 867a9f5..3bcef68 100644 --- a/server/boardClone.ts +++ b/server/boardClone.ts @@ -153,11 +153,11 @@ export async function cloneBoard(client: PoolClient, sourceBoardId: string, targ [randomUUID(), targetBoardId, row.relationship_type_id, mapped(exhibitIds, row.from_party_exhibit_id, 'related party'), mapped(exhibitIds, row.to_party_exhibit_id, 'related party'), row.note]) - const connections = await client.query<{ connection_type_id: string; from_exhibit_id: string; to_exhibit_id: string; label: string | null; tightness: number; tag_style: string }>( - 'SELECT connection_type_id,from_exhibit_id,to_exhibit_id,label,tightness,tag_style FROM osint.exhibit_connections WHERE board_id=$1', [sourceBoardId]) + const connections = await client.query<{ connection_type_id: string; from_exhibit_id: string; to_exhibit_id: string; label: string | null; tightness: number; tag_style: string; tag_position_percent: number; tag_lateral_offset: number }>( + 'SELECT connection_type_id,from_exhibit_id,to_exhibit_id,label,tightness,tag_style,tag_position_percent,tag_lateral_offset FROM osint.exhibit_connections WHERE board_id=$1', [sourceBoardId]) for (const row of connections.rows) await client.query(`INSERT INTO osint.exhibit_connections - (id,board_id,connection_type_id,from_exhibit_id,to_exhibit_id,label,tightness,tag_style) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`, - [randomUUID(), targetBoardId, row.connection_type_id, mapped(exhibitIds, row.from_exhibit_id, 'connection source'), mapped(exhibitIds, row.to_exhibit_id, 'connection target'), row.label, row.tightness, row.tag_style]) + (id,board_id,connection_type_id,from_exhibit_id,to_exhibit_id,label,tightness,tag_style,tag_position_percent,tag_lateral_offset) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, + [randomUUID(), targetBoardId, row.connection_type_id, mapped(exhibitIds, row.from_exhibit_id, 'connection source'), mapped(exhibitIds, row.to_exhibit_id, 'connection target'), row.label, row.tightness, row.tag_style, row.tag_position_percent, row.tag_lateral_offset]) const sources = await client.query<{ exhibit_id: string; source_document_exhibit_id: string; source_region_id: string | null }>( `SELECT s.exhibit_id,s.source_document_exhibit_id,s.source_region_id FROM osint.exhibit_sources s diff --git a/server/levelRepository.ts b/server/levelRepository.ts index 8b68312..2a27ffb 100644 --- a/server/levelRepository.ts +++ b/server/levelRepository.ts @@ -93,8 +93,8 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve `SELECT m.folder_exhibit_id, m.child_exhibit_id, m.sort_order, child.xpos, child.ypos FROM osint.folder_memberships m JOIN osint.exhibits child ON child.id = m.child_exhibit_id WHERE m.board_id = $1 ORDER BY m.sort_order, m.child_exhibit_id`, [level.board_id]), - pool.query<{ id: string; from_exhibit_id: string; to_exhibit_id: string; label: string | null; tightness: number; tag_style: 'luggage' | 'compact' }>( - `SELECT id, from_exhibit_id, to_exhibit_id, label, tightness, tag_style FROM osint.exhibit_connections WHERE board_id = $1 ORDER BY created_at, id`, [level.board_id]), + pool.query<{ id: string; from_exhibit_id: string; to_exhibit_id: string; label: string | null; tightness: number; tag_style: 'luggage' | 'compact'; tag_position_percent: number; tag_lateral_offset: number }>( + `SELECT id, from_exhibit_id, to_exhibit_id, label, tightness, tag_style, tag_position_percent, tag_lateral_offset FROM osint.exhibit_connections WHERE board_id = $1 ORDER BY created_at, id`, [level.board_id]), pool.query<{ exhibit_id: string; field_key: string; value: string }>( `SELECT v.exhibit_id, f.field_key, v.value FROM osint.exhibit_metadata_text_values v JOIN osint.metadata_fields f ON f.id = v.field_id WHERE f.board_id = $1 ORDER BY f.field_key`, [level.board_id]), @@ -156,7 +156,8 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve ...(authorMode && row.expected_party_kind ? { expectedPartyKind: row.expected_party_kind } : {}), resolvedPartyExhibitId: row.resolved_party_exhibit_id || undefined })) return { id: level.slug, title: level.title, subtitle: level.subtitle, documents, evidence, relations, connections: connectionsResult.rows.map(row => ({ id: row.id, fromEvidenceId: row.from_exhibit_id, toEvidenceId: row.to_exhibit_id, - label: row.label || undefined, tightness: row.tightness, tagStyle: row.tag_style })), + label: row.label || undefined, tightness: row.tightness, tagStyle: row.tag_style, + tagPosition: row.tag_position_percent, tagOffset: row.tag_lateral_offset })), viewport: { x: level.viewport_x, y: level.viewport_y, zoom: level.viewport_zoom }, updatedAt: level.updated_at.toISOString(), timelineRange: timelineResult.rows[0] ? { start: timelineResult.rows[0].range_start, end: timelineResult.rows[0].range_end } : undefined, brief: { body: briefResult.rows[0]?.body || '', concepts }, levelStatus: level.status, editingAllowed: editingEnabled, @@ -285,8 +286,11 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve if (!allIds.includes(connection.fromEvidenceId) || !allIds.includes(connection.toEvidenceId) || connection.fromEvidenceId === connection.toEvidenceId) throw new Error('Connection references an unknown or identical exhibit') const tightness = Math.max(0, Math.min(100, Math.round(Number(connection.tightness ?? 65)))) const tagStyle = connection.tagStyle === 'compact' ? 'compact' : 'luggage' - await client.query(`INSERT INTO osint.exhibit_connections (id,board_id,connection_type_id,from_exhibit_id,to_exhibit_id,label,tightness,tag_style) - VALUES ($1,$2,'thread',$3,$4,$5,$6,$7)`, [connection.id, level.board_id, connection.fromEvidenceId, connection.toEvidenceId, connection.label?.trim() || null, tightness, tagStyle]) + const tagPosition = Math.max(0, Math.min(100, Math.round(Number(connection.tagPosition ?? 50)))) + const lateralLimit = Math.round(10 + (100 - tightness) * .6) + const tagOffset = Math.max(-lateralLimit, Math.min(lateralLimit, Math.round(Number(connection.tagOffset ?? 0)))) + await client.query(`INSERT INTO osint.exhibit_connections (id,board_id,connection_type_id,from_exhibit_id,to_exhibit_id,label,tightness,tag_style,tag_position_percent,tag_lateral_offset) + VALUES ($1,$2,'thread',$3,$4,$5,$6,$7,$8,$9)`, [connection.id, level.board_id, connection.fromEvidenceId, connection.toEvidenceId, connection.label?.trim() || null, tightness, tagStyle, tagPosition, tagOffset]) } for (const exhibit of state.evidence.filter(item => item.sourceDocumentId)) { if (!documentIds.has(exhibit.sourceDocumentId!)) throw new Error('Exhibit source references an unknown document') diff --git a/server/migrations.integration.test.ts b/server/migrations.integration.test.ts index bda3451..dab946b 100644 --- a/server/migrations.integration.test.ts +++ b/server/migrations.integration.test.ts @@ -33,7 +33,7 @@ suite('PostgreSQL migrations', () => { const migrationsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations') const firstRun: string[] = [] await runMigrations(testDatabaseUrl, migrationsDir, message => firstRun.push(message)) - expect(firstRun.filter(message => message.startsWith('apply '))).toHaveLength(10) + expect(firstRun.filter(message => message.startsWith('apply '))).toHaveLength(11) const client = new Client({ connectionString: testDatabaseUrl }) await client.connect() @@ -47,14 +47,14 @@ suite('PostgreSQL migrations', () => { ])) expect(tableNames).not.toEqual(expect.arrayContaining(['cases', 'widgets', 'widget_relations', 'playthroughs'])) const ledger = await client.query<{ count: string }>('SELECT COUNT(*)::text AS count FROM osint.schema_migrations') - expect(ledger.rows[0].count).toBe('10') + expect(ledger.rows[0].count).toBe('11') const connectionColumns = await client.query<{ column_name: string }>(`SELECT column_name FROM information_schema.columns WHERE table_schema='osint' AND table_name='exhibit_connections'`) - expect(connectionColumns.rows.map(row => row.column_name)).toEqual(expect.arrayContaining(['label', 'tightness', 'tag_style'])) + expect(connectionColumns.rows.map(row => row.column_name)).toEqual(expect.arrayContaining(['label', 'tightness', 'tag_style', 'tag_position_percent', 'tag_lateral_offset'])) await client.end() const secondRun: string[] = [] await runMigrations(testDatabaseUrl, migrationsDir, message => secondRun.push(message)) - expect(secondRun.filter(message => message.startsWith('skip '))).toHaveLength(10) + expect(secondRun.filter(message => message.startsWith('skip '))).toHaveLength(11) expect(secondRun.some(message => message.startsWith('apply '))).toBe(false) }) }) diff --git a/src/App.tsx b/src/App.tsx index 3190c38..ec6ab6e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { BookOpen, Building2, CalendarClock, ChevronRight, CircleHelp, FileText, FolderOpen, Hand, Image as ImageIcon, Link2, Minus, MousePointer2, Network, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, UserRound, X, ZoomIn, ZoomOut } from 'lucide-react' import type { BriefConcept, CaseDocument, CaseState, Connection, Evidence, LevelBrief, OrganizationKind, PartyKind, SourceFileType, TimelineRange, WidgetRelation } from './types' -import { clampBoardZoom, containedIds, dateValue, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, relationPosition, threadCurve, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain' +import { clampBoardZoom, containedIds, dateValue, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, projectThreadTag, relationPosition, threadCurve, threadTagLateralLimit, threadTagPlacement, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain' import { documentWidget, exhibitWidget } from './exhibitRegistry' const BOARD_W = 2400 @@ -197,7 +197,7 @@ export function App() { setLinkFrom(null); setThreadDraft(existing); setStatus('THREAD ALREADY EXISTS · EDITING TAG') return } - setThreadDraft({ id: uid('connection'), fromEvidenceId: linkFrom, toEvidenceId: targetId, tightness: 65, tagStyle: 'luggage' }) + setThreadDraft({ id: uid('connection'), fromEvidenceId: linkFrom, toEvidenceId: targetId, tightness: 65, tagStyle: 'luggage', tagPosition: 50, tagOffset: 0 }) setLinkFrom(null) if (caseState.evidence.some(item => item.id === targetId)) setSelected(targetId) } @@ -453,12 +453,13 @@ function EmptyArchive({ canEdit, onCreated }: { canEdit: boolean; onCreated: (le } function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCreatedConnectionId, tool, boardRef, update, onCardClick, onConnectionTarget, onEditConnection, onOpenSource, onEditFolder, onEditFile, onEditEvent, onEditParty }: { state: CaseState; selected: string | null; linkFrom: string | null; recentlyCreatedExhibitId: string | null; recentlyCreatedConnectionId: string | null; tool: 'move' | 'hand'; boardRef: React.RefObject; update: (fn: (s: CaseState) => CaseState) => void; onCardClick: (id: string) => void; onConnectionTarget: (id: string) => void; onEditConnection: (connection: Connection) => void; onOpenSource: (id: string) => void; onEditFolder: (id: string) => void; onEditFile: (id: string) => void; onEditEvent: (id: string) => void; onEditParty: (id: string) => void }) { - const drag = useRef<{ kind: 'pan' | 'widget' | 'relation'; id?: string; startX: number; startY: number; originX: number; originY: number; moved?: boolean } | null>(null) + const drag = useRef<{ kind: 'pan' | 'widget' | 'relation' | 'thread-tag'; id?: string; startX: number; startY: number; originX: number; originY: number; moved?: boolean } | null>(null) const suppressClick = useRef(false) const touchPoints = useRef(new Map()) const pinchDistance = useRef(null) const [threadPointer, setThreadPointer] = useState<{ x: number; y: number } | null>(null) const [expandedThreadTagId, setExpandedThreadTagId] = useState(null) + const [draggingThreadTagId, setDraggingThreadTagId] = useState(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) => { @@ -502,6 +503,14 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr drag.current = { kind: target?.kind || 'pan', id: target?.id, startX: event.clientX, startY: event.clientY, originX: widget?.x ?? position?.x ?? state.viewport.x, originY: widget?.y ?? position?.y ?? state.viewport.y } try { event.currentTarget.setPointerCapture(event.pointerId) } catch { /* Synthetic test events have no active native pointer. */ } } + const threadTagPointerDown = (event: React.PointerEvent, id: string) => { + event.stopPropagation() + if (event.button !== 0) return + event.preventDefault() + setDraggingThreadTagId(id) + drag.current = { kind: 'thread-tag', id, startX: event.clientX, startY: event.clientY, originX: 0, originY: 0 } + try { event.currentTarget.setPointerCapture(event.pointerId) } catch { /* Synthetic test events have no active native pointer. */ } + } const trackThreadPointer = (event: React.PointerEvent) => { if (linkFrom && boardRef.current) { const bounds = boardRef.current.getBoundingClientRect() @@ -530,7 +539,18 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr if (!drag.current) return const dx = event.clientX - drag.current.startX, dy = event.clientY - drag.current.startY if (Math.abs(dx) + Math.abs(dy) > 4) drag.current.moved = true - if (drag.current.kind === 'widget') update(s => { const next = moveBoardPoint({ x: drag.current!.originX, y: drag.current!.originY }, { x: dx, y: dy }, s.viewport.zoom); return { ...s, evidence: s.evidence.map(e => e.id === drag.current!.id ? { ...e, ...next } : e) } }) + if (drag.current.kind === 'thread-tag' && boardRef.current) { + if (drag.current.moved) setExpandedThreadTagId(null) + const connection = state.connections.find(item => item.id === drag.current!.id) + const from = connection ? pointForId(connection.fromEvidenceId) : undefined + const to = connection ? pointForId(connection.toEvidenceId) : undefined + if (connection && from && to) { + const bounds = boardRef.current.getBoundingClientRect() + const pointer = { x: (event.clientX - bounds.left - state.viewport.x) / state.viewport.zoom, y: (event.clientY - bounds.top - state.viewport.y) / state.viewport.zoom } + const placement = projectThreadTag(from, to, connection.tightness ?? 65, pointer) + update(s => ({ ...s, connections: s.connections.map(item => item.id === connection.id ? { ...item, tagPosition: placement.positionPercent, tagOffset: placement.lateralOffset } : item) })) + } + } else if (drag.current.kind === 'widget') update(s => { const next = moveBoardPoint({ x: drag.current!.originX, y: drag.current!.originY }, { x: dx, y: dy }, s.viewport.zoom); return { ...s, evidence: s.evidence.map(e => e.id === drag.current!.id ? { ...e, ...next } : e) } }) else if (drag.current.kind === 'relation') update(s => { const next = moveBoardPoint({ x: drag.current!.originX, y: drag.current!.originY }, { x: dx, y: dy }, s.viewport.zoom); return { ...s, relations: s.relations.map(relation => relation.id === drag.current!.id ? { ...relation, config: { ...(relation.config || {}), ...next } } : relation) } }) else update(s => ({ ...s, viewport: panViewport({ ...s.viewport, x: drag.current!.originX, y: drag.current!.originY }, { x: dx, y: dy }) })) } @@ -541,6 +561,7 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr } if (drag.current) suppressClick.current = Boolean(drag.current.moved) drag.current = null + setDraggingThreadTagId(null) } 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 @@ -558,7 +579,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 })} {previewOrigin && threadPointer && } - {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 compact = connection.tagStyle === 'compact'; const expanded = !compact && expandedThreadTagId === connection.id; return })} + {state.connections.map(connection => { const p1 = pointForId(connection.fromEvidenceId), p2 = pointForId(connection.toEvidenceId); if (!p1 || !p2) return null; const placement = threadTagPlacement(p1, p2, connection.tightness, connection.tagPosition, connection.tagOffset); const compact = connection.tagStyle === 'compact'; const expanded = !compact && expandedThreadTagId === connection.id; const dragging = draggingThreadTagId === connection.id; return })} {state.evidence.filter(event => event.type === 'event').flatMap(event => (event.supportingEvidenceIds || []).flatMap(evidenceId => { const evidence = byId.get(evidenceId) @@ -679,13 +700,19 @@ function ThreadEditor({ connection, sourceName, targetName, isNew, onClose, onSa const [label, setLabel] = useState(connection.label || '') const [tightness, setTightness] = useState(connection.tightness ?? 65) const [tagStyle, setTagStyle] = useState<'luggage' | 'compact'>(connection.tagStyle === 'compact' ? 'compact' : 'luggage') - const save = (tag = label) => onSave({ ...connection, label: tag.trim() || undefined, tightness, tagStyle }) + const [tagPosition, setTagPosition] = useState(connection.tagPosition ?? 50) + const save = (tag = label) => { + const lateralLimit = threadTagLateralLimit(tightness) + onSave({ ...connection, label: tag.trim() || undefined, tightness, tagStyle, tagPosition, + tagOffset: Math.max(-lateralLimit, Math.min(lateralLimit, connection.tagOffset ?? 0)) }) + } return
{ event.preventDefault(); save() }}>
{isNew ? 'Add relation tag' : 'Edit red thread'}
RED THREAD · INVESTIGATOR RELATION
{sourceName}{targetName}

What does this connection mean? Add a short tag if the thread represents a specific claim.

TAG PRESENTATION
+
{!isNew && }{isNew && }
diff --git a/src/boardDomain.test.ts b/src/boardDomain.test.ts index 56e38db..1d69f87 100644 --- a/src/boardDomain.test.ts +++ b/src/boardDomain.test.ts @@ -11,7 +11,10 @@ import { normalizeCase, panViewport, relationPosition, + projectThreadTag, threadCurve, + threadTagLateralLimit, + threadTagPlacement, timelinePositionPercent, timelineRange, zoomFromWheel, @@ -31,6 +34,18 @@ describe('red thread geometry', () => { it('clamps tightness to its normalized percentage range', () => { expect(threadCurve({ x: 0, y: 0 }, { x: 100, y: 0 }, 200)).toEqual(threadCurve({ x: 0, y: 0 }, { x: 100, y: 0 }, 100)) }) + + it('places and projects tags by percentage along the curve', () => { + const from = { x: 0, y: 0 }, to = { x: 200, y: 0 } + expect(threadTagPlacement(from, to, 100, 25, 0)).toMatchObject({ x: 50, y: 0, positionPercent: 25 }) + expect(projectThreadTag(from, to, 100, { x: 150, y: 8 })).toMatchObject({ positionPercent: 75, lateralOffset: 8 }) + }) + + it('reduces lateral tag travel as the thread becomes taut', () => { + expect(threadTagLateralLimit(0)).toBe(70) + expect(threadTagLateralLimit(100)).toBe(10) + expect(threadTagPlacement({ x: 0, y: 0 }, { x: 200, y: 0 }, 100, 50, 100).lateralOffset).toBe(10) + }) }) const folder: Evidence = { diff --git a/src/boardDomain.ts b/src/boardDomain.ts index 3b80b29..b68e938 100644 --- a/src/boardDomain.ts +++ b/src/boardDomain.ts @@ -2,16 +2,71 @@ import type { CaseState, Evidence, TimelineRange, Viewport, WidgetRelation } fro export interface BoardPoint { x: number; y: number } -export function threadCurve(from: BoardPoint, to: BoardPoint, tightness = 65) { +function threadControls(from: BoardPoint, to: BoardPoint, tightness = 65) { const tautness = Math.max(0, Math.min(100, Number(tightness) || 0)) / 100 const distance = Math.hypot(to.x - from.x, to.y - from.y) const sag = (1 - tautness) * Math.min(190, Math.max(45, distance * .28)) const c1 = { x: from.x + (to.x - from.x) / 3, y: from.y + (to.y - from.y) / 3 + sag } const c2 = { x: from.x + (to.x - from.x) * 2 / 3, y: from.y + (to.y - from.y) * 2 / 3 + sag } - const midpoint = { - x: (from.x + 3 * c1.x + 3 * c2.x + to.x) / 8, - y: (from.y + 3 * c1.y + 3 * c2.y + to.y) / 8, + return { c1, c2 } +} + +function cubicPoint(from: BoardPoint, c1: BoardPoint, c2: BoardPoint, to: BoardPoint, t: number) { + const inverse = 1 - t + return { + x: inverse ** 3 * from.x + 3 * inverse ** 2 * t * c1.x + 3 * inverse * t ** 2 * c2.x + t ** 3 * to.x, + y: inverse ** 3 * from.y + 3 * inverse ** 2 * t * c1.y + 3 * inverse * t ** 2 * c2.y + t ** 3 * to.y, } +} + +function cubicTangent(from: BoardPoint, c1: BoardPoint, c2: BoardPoint, to: BoardPoint, t: number) { + const inverse = 1 - t + return { + x: 3 * inverse ** 2 * (c1.x - from.x) + 6 * inverse * t * (c2.x - c1.x) + 3 * t ** 2 * (to.x - c2.x), + y: 3 * inverse ** 2 * (c1.y - from.y) + 6 * inverse * t * (c2.y - c1.y) + 3 * t ** 2 * (to.y - c2.y), + } +} + +export function threadTagLateralLimit(tightness = 65) { + const normalized = Math.max(0, Math.min(100, Number(tightness) || 0)) + return Math.round(10 + (100 - normalized) * .6) +} + +export function threadTagPlacement(from: BoardPoint, to: BoardPoint, tightness = 65, positionPercent = 50, lateralOffset = 0) { + const { c1, c2 } = threadControls(from, to, tightness) + const position = Math.max(5, Math.min(95, Number(positionPercent) || 50)) + const t = position / 100 + const point = cubicPoint(from, c1, c2, to, t) + const tangent = cubicTangent(from, c1, c2, to, t) + const length = Math.hypot(tangent.x, tangent.y) || 1 + const normal = { x: -tangent.y / length, y: tangent.x / length } + const maxLateralOffset = threadTagLateralLimit(tightness) + const offset = Math.max(-maxLateralOffset, Math.min(maxLateralOffset, Number(lateralOffset) || 0)) + return { x: point.x + normal.x * offset, y: point.y + normal.y * offset, positionPercent: position, lateralOffset: offset, maxLateralOffset } +} + +export function projectThreadTag(from: BoardPoint, to: BoardPoint, tightness: number, pointer: BoardPoint) { + const { c1, c2 } = threadControls(from, to, tightness) + let bestT = .5 + let bestDistance = Number.POSITIVE_INFINITY + for (let index = 5; index <= 95; index += 1) { + const t = index / 100 + const point = cubicPoint(from, c1, c2, to, t) + const distance = (pointer.x - point.x) ** 2 + (pointer.y - point.y) ** 2 + if (distance < bestDistance) { bestDistance = distance; bestT = t } + } + const point = cubicPoint(from, c1, c2, to, bestT) + const tangent = cubicTangent(from, c1, c2, to, bestT) + const length = Math.hypot(tangent.x, tangent.y) || 1 + const normal = { x: -tangent.y / length, y: tangent.x / length } + const maxLateralOffset = threadTagLateralLimit(tightness) + const lateralOffset = Math.max(-maxLateralOffset, Math.min(maxLateralOffset, (pointer.x - point.x) * normal.x + (pointer.y - point.y) * normal.y)) + return { positionPercent: Math.round(bestT * 100), lateralOffset: Math.round(lateralOffset), maxLateralOffset } +} + +export function threadCurve(from: BoardPoint, to: BoardPoint, tightness = 65) { + const { c1, c2 } = threadControls(from, to, tightness) + const midpoint = cubicPoint(from, c1, c2, to, .5) return { path: `M ${from.x} ${from.y} C ${c1.x} ${c1.y}, ${c2.x} ${c2.y}, ${to.x} ${to.y}`, midpoint } } diff --git a/src/styles.css b/src/styles.css index 4afe3f3..1ffa841 100644 --- a/src/styles.css +++ b/src/styles.css @@ -68,8 +68,10 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; } .luggage-tag.luggage-tag { background: linear-gradient(100deg, #aa8755, #c7a773 52%, #a9834f); border: 1px solid #d3b681; color: #33291c; clip-path: polygon(13px 0, calc(100% - 13px) 0, 100% 14px, 100% 100%, 0 100%, 0 14px); transform-origin: 50% 8px; transition: transform .22s ease, filter .22s ease; box-shadow: 5px 7px 0 #020b0980, inset 0 0 18px #60452233; } .luggage-tag.luggage-tag::before { content: ''; position: absolute; z-index: 3; top: 6px; left: 50%; width: 10px; height: 10px; translate: -50% 0; border-radius: 50%; background: #12211d; border: 2px solid #70583b; box-shadow: 0 0 0 2px #c5a66f, inset 1px 1px 2px #000; } .luggage-tag.luggage-tag::after { content: ''; position: absolute; left: 9px; right: 9px; bottom: -1px; height: 4px; background: #745a37; clip-path: polygon(0 0, 5% 50%, 12% 0, 20% 70%, 28% 0, 40% 60%, 49% 0, 61% 70%, 73% 0, 83% 60%, 91% 0, 100% 50%, 100% 100%, 0 100%); } -.thread-tag { position: absolute; z-index: 7; border: 0; cursor: pointer; } +.thread-tag { position: absolute; z-index: 7; border: 0; cursor: grab; touch-action: none; transition: left .1s ease-out, top .1s ease-out, transform .22s ease, filter .22s ease, opacity .15s ease; } .board-viewport.threading .thread-tag { pointer-events: none; opacity: .72; } +.thread-tag.dragging { z-index: 15; cursor: grabbing; transition: transform .12s ease, filter .12s ease; filter: drop-shadow(0 0 7px #d99a58aa); } +.thread-tag-position { position: absolute; z-index: 8; left: 50%; top: -22px; translate: -50% 0; min-width: 34px; padding: 3px 5px; border: 1px solid #9a683d; background: #102a24; color: #e6a45e; font: 7px IBM Plex Mono; box-shadow: 2px 3px #02090799; } .thread-tag.untagged { width: 13px; height: 13px; padding: 0; transform: translate(-50%, -50%); border: 2px solid #611d1c; border-radius: 50%; background: #a63531; box-shadow: 1px 2px #020907aa; } .thread-tag.untagged i { display: none; } .thread-tag.untagged:hover, .thread-tag.untagged:focus-visible { background: #e07158; box-shadow: 0 0 0 4px #b23e3544; } @@ -195,7 +197,7 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; } .timeline-key { border-left: 1px solid #314a43; padding-left: 24px; font: 8px IBM Plex Mono; color: #759087; display: flex; gap: 18px; }.timeline-key span { display: flex; gap: 5px; }.timeline-key i { width: 7px; height: 7px; background: #8eb3a7; rotate: 45deg; }.timeline-key .amber i { background: #eea458; } .window { position: fixed; z-index: 30; background: #bfc4bc; color: #14201d; border: 2px solid #cfd3cc; box-shadow: 5px 6px 0 #020a08, 0 0 0 1px #45534e; } .timeline-editor { width: min(520px, 88vw); }.timeline-editor > div { padding: 24px 27px; }.timeline-editor p { margin: 12px 0 18px; font: 12px/1.5 Special Elite; }.timeline-range-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 20px; } -.thread-editor { width: min(520px, 88vw); }.thread-editor > div { padding: 24px 27px; }.thread-editor > div > small { color: #8a4b32; font: 600 8px IBM Plex Mono; letter-spacing: .14em; }.thread-editor p { margin: 14px 0; font: 12px/1.5 Special Elite; }.thread-endpoints { margin-top: 15px; display: grid; grid-template-columns: minmax(0,1fr) 70px minmax(0,1fr); align-items: center; gap: 9px; }.thread-endpoints b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 600 9px IBM Plex Mono; }.thread-endpoints b:last-child { text-align: right; }.thread-endpoints i { height: 3px; background: #982e2b; box-shadow: 0 1px #5b1d1b; }.thread-tightness { margin: 20px 0; }.thread-tightness output { margin-left: auto; color: #9a332e; }.thread-tightness input { accent-color: #9b302d; padding: 0; }.thread-tightness > small { display: flex; justify-content: space-between; color: #68736d; font: 7px IBM Plex Mono; }.thread-editor .folder-editor-actions > span { flex: 1; }.folder-editor-actions button.danger { color: #7c2925; border-color: #a25b55; } +.thread-editor { width: min(520px, 88vw); }.thread-editor > div { padding: 24px 27px; }.thread-editor > div > small { color: #8a4b32; font: 600 8px IBM Plex Mono; letter-spacing: .14em; }.thread-editor p { margin: 14px 0; font: 12px/1.5 Special Elite; }.thread-endpoints { margin-top: 15px; display: grid; grid-template-columns: minmax(0,1fr) 70px minmax(0,1fr); align-items: center; gap: 9px; }.thread-endpoints b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 600 9px IBM Plex Mono; }.thread-endpoints b:last-child { text-align: right; }.thread-endpoints i { height: 3px; background: #982e2b; box-shadow: 0 1px #5b1d1b; }.thread-tightness { margin: 20px 0; }.thread-tightness output { margin-left: auto; color: #9a332e; }.thread-tightness input { accent-color: #9b302d; padding: 0; }.thread-tightness > small { display: flex; justify-content: space-between; color: #68736d; font: 7px IBM Plex Mono; }.thread-position-control { margin-top: 17px; }.thread-position-control output { margin-left: auto; color: #9a5c2f; }.thread-position-control input { accent-color: #9a5c2f; padding: 0; }.thread-position-control > small { color: #68736d; font: 7px/1.4 IBM Plex Mono; }.thread-editor .folder-editor-actions > span { flex: 1; }.folder-editor-actions button.danger { color: #7c2925; border-color: #a25b55; } .tag-style-picker { margin: 16px 0 4px; padding: 0; border: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }.tag-style-picker legend { margin-bottom: 6px; color: #44504c; font: 600 8px IBM Plex Mono; letter-spacing: .1em; }.tag-style-picker label { position: relative; display: grid; gap: 5px; padding: 10px; border: 1px solid #909991; background: #d6d7cf; cursor: pointer; }.tag-style-picker label.selected { border-color: #8f3833; background: #e1d4bd; box-shadow: inset 3px 0 #9c3631; }.tag-style-picker input { position: absolute; opacity: 0; }.tag-style-picker label > span { display: flex; align-items: center; gap: 7px; color: #344b44; font: 600 8px IBM Plex Mono; }.tag-style-picker label > small { color: #69746e; font: 7px IBM Plex Mono; }.tag-style-luggage i { width: 15px; height: 21px; background: #b99562; border: 1px solid #7b6040; clip-path: polygon(3px 0,12px 0,15px 3px,15px 21px,0 21px,0 3px); }.tag-style-compact i { width: 25px; height: 8px; border-left: 7px solid #a63531; background: #d7c9a9; box-shadow: 1px 1px #6e6250; } .window > header { height: 31px; display: flex; align-items: center; gap: 8px; padding: 0 5px 0 9px; color: #dfe9e4; background: #183f36; font: 500 11px IBM Plex Mono; cursor: move; touch-action: none; } .window > header span { flex: 1; }.window > header button { width: 22px; height: 21px; display: grid; place-items: center; padding: 0; background: #b7bcb4; border: 1px outset white; color: #17221f; cursor: pointer; } diff --git a/src/types.ts b/src/types.ts index 41b6c88..2a59263 100644 --- a/src/types.ts +++ b/src/types.ts @@ -64,6 +64,10 @@ export interface Connection { /** Percentage from slack (0) to taut (100). */ tightness?: number tagStyle?: 'luggage' | 'compact' + /** Percentage along the thread from its source exhibit. */ + tagPosition?: number + /** Signed perpendicular distance from the thread, constrained by tightness. */ + tagOffset?: number } export interface Viewport { x: number; y: number; zoom: number }