From 6f183daca8226ffeab85474828e39340f2031b04 Mon Sep 17 00:00:00 2001 From: jenstandstad Date: Fri, 14 Aug 2026 17:07:30 +0200 Subject: [PATCH] feat: support compact and luggage relation tags --- README.md | 2 +- e2e/mystery.acceptance.spec.ts | 8 +++++++- migrations/010_connection_tag_style.sql | 5 +++++ server/api.integration.test.ts | 6 +++--- server/boardClone.ts | 8 ++++---- server/levelRepository.ts | 11 ++++++----- server/migrations.integration.test.ts | 8 ++++---- src/App.tsx | 8 +++++--- src/styles.css | 5 +++++ src/types.ts | 1 + 10 files changed, 41 insertions(+), 21 deletions(-) create mode 100644 migrations/010_connection_tag_style.sql diff --git a/README.md b/README.md index a440721..387190b 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ The current POC defines four exhibit families and corresponding frontend widgets The **Party** family has distinct Person and Organization subtypes (businesses are organizations). Names begin as concepts in the level brief. The investigator classifies each concept, which creates the appropriate dossier exhibit and records the resolution. Expected classifications remain author-only. Dossiers store aliases and normalized evidence associations, but parties remain identity objects rather than special folders. -**Red thread** is a normalized connection between any two exhibits, including expanded source documents. Each connection may carry an investigator-authored relation tag and a persisted tightness percentage. Relation labels reuse the investigator note's luggage-tag primitive: they hang from the thread, rotate into a readable position on first click, and open the thread editor on the next click. When a connected source document is retracted into its folder, the visible endpoint follows it to the folder until it is expanded again. +**Red thread** is a normalized connection between any two exhibits, including expanded source documents. Each connection may carry an investigator-authored relation tag, a persisted tightness percentage, and a presentation choice. `LUGGAGE` reuses the investigator note's expressive hanging tag, rotating to read before opening the editor; `COMPACT` keeps the original knot-and-label treatment and opens the editor directly. Both remain available while the POC establishes which works best. When a connected source document is retracted into its folder, the visible endpoint follows it to the folder until it is expanded again. Folder ownership is stored as a normalized membership. The contained document exhibit owns its expanded `xpos` and `ypos`. For deterministic collapse behavior, one exhibit has at most one owning folder; two exhibits may reuse the same immutable asset when the same source file must appear in multiple folders. diff --git a/e2e/mystery.acceptance.spec.ts b/e2e/mystery.acceptance.spec.ts index 757130d..0b76dad 100644 --- a/e2e/mystery.acceptance.spec.ts +++ b/e2e/mystery.acceptance.spec.ts @@ -101,10 +101,16 @@ test('a cloned Glass Harbor level can be solved without modifying its template', await expect(relationTag).toHaveAttribute('aria-expanded', 'true') await relationTag.click() await expect(page.getByText('Edit red thread')).toBeVisible() + 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/) + await expect(relationTag).not.toHaveClass(/\bluggage-tag\b/) + await relationTag.click() + await expect(page.getByText('Edit red thread')).toBeVisible() 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 })) + expect(solved.connections).toContainEqual(expect.objectContaining({ label: 'Proof Elias is the driver', tightness: 85, tagStyle: 'compact' })) const template = await (await request.get('/api/templates')).json() as { slug: string; currentVersion: number }[] expect(template).toContainEqual(expect.objectContaining({ slug: 'glass-harbor', currentVersion: 1 })) diff --git a/migrations/010_connection_tag_style.sql b/migrations/010_connection_tag_style.sql new file mode 100644 index 0000000..432053b --- /dev/null +++ b/migrations/010_connection_tag_style.sql @@ -0,0 +1,5 @@ +ALTER TABLE osint.exhibit_connections + ADD COLUMN tag_style TEXT NOT NULL DEFAULT 'luggage' + CHECK (tag_style IN ('luggage', 'compact')); + +COMMENT ON COLUMN osint.exhibit_connections.tag_style IS 'Player-selected relation label presentation while both POC treatments are evaluated'; diff --git a/server/api.integration.test.ts b/server/api.integration.test.ts index c8c18e5..d9a2fd7 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 }] + state.connections = [{ id: randomUUID(), fromEvidenceId: folderId, toEvidenceId: documentId, label: 'Primary source', tightness: 85, tagStyle: 'compact' }] 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 })) + expect(loaded.connections).toContainEqual(expect.objectContaining({ fromEvidenceId: folderId, toEvidenceId: documentId, label: 'Primary source', tightness: 85, tagStyle: 'compact' })) 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, toEvidenceId: clone.documents[0].id }) + expect(clone.connections[0]).toMatchObject({ label: 'Primary source', tightness: 85, tagStyle: 'compact', 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 be2b6d8..867a9f5 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 }>( - 'SELECT connection_type_id,from_exhibit_id,to_exhibit_id,label,tightness 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 }>( + 'SELECT connection_type_id,from_exhibit_id,to_exhibit_id,label,tightness,tag_style 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) VALUES ($1,$2,$3,$4,$5,$6,$7)`, - [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]) + (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]) 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 08e8c9e..8b68312 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 }>( - `SELECT id, from_exhibit_id, to_exhibit_id, label, tightness 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' }>( + `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<{ 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,7 @@ 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 })), + label: row.label || undefined, tightness: row.tightness, tagStyle: row.tag_style })), 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, @@ -284,8 +284,9 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve requireUuid(connection.id, 'Connection id') 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)))) - await client.query(`INSERT INTO osint.exhibit_connections (id,board_id,connection_type_id,from_exhibit_id,to_exhibit_id,label,tightness) - VALUES ($1,$2,'thread',$3,$4,$5,$6)`, [connection.id, level.board_id, connection.fromEvidenceId, connection.toEvidenceId, connection.label?.trim() || null, tightness]) + 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]) } 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 af9ed57..bda3451 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(9) + expect(firstRun.filter(message => message.startsWith('apply '))).toHaveLength(10) 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('9') + expect(ledger.rows[0].count).toBe('10') 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'])) + expect(connectionColumns.rows.map(row => row.column_name)).toEqual(expect.arrayContaining(['label', 'tightness', 'tag_style'])) await client.end() const secondRun: string[] = [] await runMigrations(testDatabaseUrl, migrationsDir, message => secondRun.push(message)) - expect(secondRun.filter(message => message.startsWith('skip '))).toHaveLength(9) + expect(secondRun.filter(message => message.startsWith('skip '))).toHaveLength(10) expect(secondRun.some(message => message.startsWith('apply '))).toBe(false) }) }) diff --git a/src/App.tsx b/src/App.tsx index d73f1aa..298cda5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -181,7 +181,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 }) + setThreadDraft({ id: uid('connection'), fromEvidenceId: linkFrom, toEvidenceId: targetId, tightness: 65, tagStyle: 'luggage' }) setLinkFrom(null) if (caseState.evidence.some(item => item.id === targetId)) setSelected(targetId) } @@ -517,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 })} {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 expanded = expandedThreadTagId === connection.id; return })} + {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.evidence.filter(event => event.type === 'event').flatMap(event => (event.supportingEvidenceIds || []).flatMap(evidenceId => { const evidence = byId.get(evidenceId) @@ -637,12 +637,14 @@ function TimelineRangeEditor({ range, dates, onClose, onSave }: { range?: Timeli function ThreadEditor({ connection, sourceName, targetName, isNew, onClose, onSave, onRemove }: { connection: Connection; sourceName: string; targetName: string; isNew: boolean; onClose: () => void; onSave: (connection: Connection) => void; onRemove: () => void }) { const [label, setLabel] = useState(connection.label || '') const [tightness, setTightness] = useState(connection.tightness ?? 65) - const save = (tag = label) => onSave({ ...connection, label: tag.trim() || undefined, tightness }) + const [tagStyle, setTagStyle] = useState<'luggage' | 'compact'>(connection.tagStyle === 'compact' ? 'compact' : 'luggage') + const save = (tag = label) => onSave({ ...connection, label: tag.trim() || undefined, tightness, tagStyle }) 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/styles.css b/src/styles.css index 87473f0..7bfefc6 100644 --- a/src/styles.css +++ b/src/styles.css @@ -65,6 +65,10 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; } .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; } +.thread-tag.compact { transform: translate(-50%, -50%); display: flex; align-items: center; gap: 5px; max-width: 190px; padding: 0; background: transparent; color: #241d17; } +.thread-tag.compact i { flex: 0 0 auto; width: 9px; height: 9px; border: 2px solid #611d1c; border-radius: 50%; background: #a63531; box-shadow: 1px 2px #020907aa; } +.thread-tag-compact-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 5px 7px 4px; border: 1px solid #9c917b; background: #d7c9a9; box-shadow: 2px 3px #02090799; font: 8px Special Elite; } +.thread-tag.compact:hover i, .thread-tag.compact:focus-visible i { background: #e07158; box-shadow: 0 0 0 4px #b23e3544; } .thread-tag.labelled { width: 108px; height: 154px; padding: 27px 10px 11px; transform: translate(-50%, 4px) rotate(-2deg); } .thread-tag.labelled i { display: none; } .thread-tag-content { display: grid; align-content: start; gap: 7px; height: 108px; padding-top: 10px; overflow: hidden; text-align: left; transition: transform .22s ease; } @@ -182,6 +186,7 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; } .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; } +.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; } .document-window { width: min(610px, 60vw); }.document-window.minimized { width: min(380px, 60vw); }.document-window > nav { height: 28px; padding: 7px 10px; background: #aeb4ac; border-bottom: 1px solid #727c76; font: 9px IBM Plex Mono; } diff --git a/src/types.ts b/src/types.ts index 714be9b..41b6c88 100644 --- a/src/types.ts +++ b/src/types.ts @@ -63,6 +63,7 @@ export interface Connection { label?: string /** Percentage from slack (0) to taut (100). */ tightness?: number + tagStyle?: 'luggage' | 'compact' } export interface Viewport { x: number; y: number; zoom: number }