feat: support compact and luggage relation tags
This commit is contained in:
@@ -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.
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -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 expect(relationTag).toHaveAttribute('aria-expanded', 'true')
|
||||||
await relationTag.click()
|
await relationTag.click()
|
||||||
await expect(page.getByText('Edit red thread')).toBeVisible()
|
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()
|
await page.getByRole('button', { name: 'Close thread editor', exact: true }).click()
|
||||||
|
|
||||||
const solved = await (await request.get(`/api/levels/${playable.id}`)).json()
|
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 }[]
|
const template = await (await request.get('/api/templates')).json() as { slug: string; currentVersion: number }[]
|
||||||
expect(template).toContainEqual(expect.objectContaining({ slug: 'glass-harbor', currentVersion: 1 }))
|
expect(template).toContainEqual(expect.objectContaining({ slug: 'glass-harbor', currentVersion: 1 }))
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -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 },
|
{ 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.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`, {
|
const saveResponse = await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -100,7 +100,7 @@ suite('level persistence API', () => {
|
|||||||
expect(loaded.timelineRange).toEqual(state.timelineRange)
|
expect(loaded.timelineRange).toEqual(state.timelineRange)
|
||||||
expect(loaded.evidence[0]).toMatchObject({ id: folderId, x: 685, y: 417, config: { open: true } })
|
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.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(loaded.brief.concepts).toEqual(expect.arrayContaining([
|
||||||
expect.objectContaining({ label: 'Ada Lovelace', expectedPartyKind: 'person' }),
|
expect.objectContaining({ label: 'Ada Lovelace', expectedPartyKind: 'person' }),
|
||||||
expect.objectContaining({ label: 'Analytical Engines Ltd', expectedPartyKind: 'organization' }),
|
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.documents[0].id).not.toBe(savedPlayerState.documents[0].id)
|
||||||
expect(clone.evidence.map(item => item.id)).not.toContain(folderId)
|
expect(clone.evidence.map(item => item.id)).not.toContain(folderId)
|
||||||
expect(clone.connections).toHaveLength(1)
|
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' })
|
expect(clone.evidence.find(item => item.type === 'note')).toMatchObject({ sourceRegionId: 'stamp' })
|
||||||
const clonedEvent = clone.evidence.find(item => item.type === 'event')!
|
const clonedEvent = clone.evidence.find(item => item.type === 'event')!
|
||||||
expect(clonedEvent).toMatchObject({ title: 'The meeting occurred', eventDate: '2021-04-18T14:30:00.000Z' })
|
expect(clonedEvent).toMatchObject({ title: 'The meeting occurred', eventDate: '2021-04-18T14:30:00.000Z' })
|
||||||
|
|||||||
@@ -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'),
|
[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])
|
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 }>(
|
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 FROM osint.exhibit_connections WHERE board_id=$1', [sourceBoardId])
|
'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
|
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)`,
|
(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])
|
[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 }>(
|
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
|
`SELECT s.exhibit_id,s.source_document_exhibit_id,s.source_region_id FROM osint.exhibit_sources s
|
||||||
|
|||||||
@@ -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
|
`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
|
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]),
|
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 }>(
|
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 FROM osint.exhibit_connections WHERE board_id = $1 ORDER BY created_at, id`, [level.board_id]),
|
`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 }>(
|
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
|
`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]),
|
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 }))
|
...(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,
|
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,
|
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(),
|
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,
|
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,
|
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')
|
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')
|
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 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)
|
const tagStyle = connection.tagStyle === 'compact' ? 'compact' : 'luggage'
|
||||||
VALUES ($1,$2,'thread',$3,$4,$5,$6)`, [connection.id, level.board_id, connection.fromEvidenceId, connection.toEvidenceId, connection.label?.trim() || null, tightness])
|
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)) {
|
for (const exhibit of state.evidence.filter(item => item.sourceDocumentId)) {
|
||||||
if (!documentIds.has(exhibit.sourceDocumentId!)) throw new Error('Exhibit source references an unknown document')
|
if (!documentIds.has(exhibit.sourceDocumentId!)) throw new Error('Exhibit source references an unknown document')
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ suite('PostgreSQL migrations', () => {
|
|||||||
const migrationsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations')
|
const migrationsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations')
|
||||||
const firstRun: string[] = []
|
const firstRun: string[] = []
|
||||||
await runMigrations(testDatabaseUrl, migrationsDir, message => firstRun.push(message))
|
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 })
|
const client = new Client({ connectionString: testDatabaseUrl })
|
||||||
await client.connect()
|
await client.connect()
|
||||||
@@ -47,14 +47,14 @@ suite('PostgreSQL migrations', () => {
|
|||||||
]))
|
]))
|
||||||
expect(tableNames).not.toEqual(expect.arrayContaining(['cases', 'widgets', 'widget_relations', 'playthroughs']))
|
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')
|
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'`)
|
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()
|
await client.end()
|
||||||
|
|
||||||
const secondRun: string[] = []
|
const secondRun: string[] = []
|
||||||
await runMigrations(testDatabaseUrl, migrationsDir, message => secondRun.push(message))
|
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)
|
expect(secondRun.some(message => message.startsWith('apply '))).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+5
-3
@@ -181,7 +181,7 @@ export function App() {
|
|||||||
setLinkFrom(null); setThreadDraft(existing); setStatus('THREAD ALREADY EXISTS · EDITING TAG')
|
setLinkFrom(null); setThreadDraft(existing); setStatus('THREAD ALREADY EXISTS · EDITING TAG')
|
||||||
return
|
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)
|
setLinkFrom(null)
|
||||||
if (caseState.evidence.some(item => item.id === targetId)) setSelected(targetId)
|
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 <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> })}
|
{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>}
|
{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>
|
</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; 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> })}
|
{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 <button key={`tag:${connection.id}`} aria-expanded={connection.label && !compact ? expanded : undefined} aria-label={connection.label ? `Relation tag: ${connection.label}` : 'Edit untagged red thread'} className={`thread-tag ${connection.label ? `labelled ${compact ? 'compact' : 'luggage luggage-tag'}` : 'untagged'} ${expanded ? 'expanded' : ''}`} style={{ left: midpoint.x, top: midpoint.y }} title={connection.label ? compact ? 'Edit compact relation tag' : 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 || compact || expanded) { setExpandedThreadTagId(null); onEditConnection(connection) } else setExpandedThreadTagId(connection.id) }}><i/>{connection.label && (compact ? <span className="thread-tag-compact-label">{connection.label}</span> : <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}>
|
<svg className="event-support-lines" width={BOARD_W} height={BOARD_H}>
|
||||||
{state.evidence.filter(event => event.type === 'event').flatMap(event => (event.supportingEvidenceIds || []).flatMap(evidenceId => {
|
{state.evidence.filter(event => event.type === 'event').flatMap(event => (event.supportingEvidenceIds || []).flatMap(evidenceId => {
|
||||||
const evidence = byId.get(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 }) {
|
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 [label, setLabel] = useState(connection.label || '')
|
||||||
const [tightness, setTightness] = useState(connection.tightness ?? 65)
|
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 <div className="modal-shade"><form className="window thread-editor" onSubmit={event => { event.preventDefault(); save() }}>
|
return <div className="modal-shade"><form className="window thread-editor" onSubmit={event => { event.preventDefault(); save() }}>
|
||||||
<header><Link2 size={16}/><b>{isNew ? 'Add relation tag' : 'Edit red thread'}</b><span/><button type="button" aria-label="Close thread editor" onClick={onClose}><X size={14}/></button></header>
|
<header><Link2 size={16}/><b>{isNew ? 'Add relation tag' : 'Edit red thread'}</b><span/><button type="button" aria-label="Close thread editor" onClick={onClose}><X size={14}/></button></header>
|
||||||
<div><small>RED THREAD · INVESTIGATOR RELATION</small><div className="thread-endpoints"><b>{sourceName}</b><i/><b>{targetName}</b></div>
|
<div><small>RED THREAD · INVESTIGATOR RELATION</small><div className="thread-endpoints"><b>{sourceName}</b><i/><b>{targetName}</b></div>
|
||||||
<p>What does this connection mean? Add a short tag if the thread represents a specific claim.</p>
|
<p>What does this connection mean? Add a short tag if the thread represents a specific claim.</p>
|
||||||
<label className="field"><span>RELATION TAG · OPTIONAL</span><input aria-label="Thread tag" autoFocus placeholder="e.g. Proof Elias is the driver" value={label} onChange={event => setLabel(event.target.value)}/></label>
|
<label className="field"><span>RELATION TAG · OPTIONAL</span><input aria-label="Thread tag" autoFocus placeholder="e.g. Proof Elias is the driver" value={label} onChange={event => setLabel(event.target.value)}/></label>
|
||||||
|
<fieldset className="tag-style-picker"><legend>TAG PRESENTATION</legend><label className={tagStyle === 'luggage' ? 'selected' : ''}><input type="radio" name="tag-style" value="luggage" checked={tagStyle === 'luggage'} onChange={() => setTagStyle('luggage')}/><span className="tag-style-luggage"><i/>LUGGAGE</span><small>Expressive · rotates to read</small></label><label className={tagStyle === 'compact' ? 'selected' : ''}><input type="radio" name="tag-style" value="compact" checked={tagStyle === 'compact'} onChange={() => setTagStyle('compact')}/><span className="tag-style-compact"><i/>COMPACT</span><small>Quiet · less board clutter</small></label></fieldset>
|
||||||
<label className="field thread-tightness"><span>THREAD TIGHTNESS <output>{tightness}%</output></span><input aria-label="Thread tightness" type="range" min="0" max="100" step="5" value={tightness} onChange={event => setTightness(Number(event.target.value))}/><small><span>SLACK</span><span>TAUT</span></small></label>
|
<label className="field thread-tightness"><span>THREAD TIGHTNESS <output>{tightness}%</output></span><input aria-label="Thread tightness" type="range" min="0" max="100" step="5" value={tightness} onChange={event => setTightness(Number(event.target.value))}/><small><span>SLACK</span><span>TAUT</span></small></label>
|
||||||
<div className="folder-editor-actions">{!isNew && <button className="danger" type="button" onClick={onRemove}>REMOVE THREAD</button>}<span/>{isNew && <button type="button" onClick={() => save('')}>SKIP TAG</button>}<button className="primary" type="submit">{isNew ? 'ADD TAG & TIGHTEN' : 'SAVE THREAD'}</button></div>
|
<div className="folder-editor-actions">{!isNew && <button className="danger" type="button" onClick={onRemove}>REMOVE THREAD</button>}<span/>{isNew && <button type="button" onClick={() => save('')}>SKIP TAG</button>}<button className="primary" type="submit">{isNew ? 'ADD TAG & TIGHTEN' : 'SAVE THREAD'}</button></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 { 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 i { display: none; }
|
||||||
.thread-tag.untagged:hover, .thread-tag.untagged:focus-visible { background: #e07158; box-shadow: 0 0 0 4px #b23e3544; }
|
.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 { width: 108px; height: 154px; padding: 27px 10px 11px; transform: translate(-50%, 4px) rotate(-2deg); }
|
||||||
.thread-tag.labelled i { display: none; }
|
.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; }
|
.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; }
|
.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; }
|
.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-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 { 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; }
|
.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; }
|
.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; }
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export interface Connection {
|
|||||||
label?: string
|
label?: string
|
||||||
/** Percentage from slack (0) to taut (100). */
|
/** Percentage from slack (0) to taut (100). */
|
||||||
tightness?: number
|
tightness?: number
|
||||||
|
tagStyle?: 'luggage' | 'compact'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Viewport { x: number; y: number; zoom: number }
|
export interface Viewport { x: number; y: number; zoom: number }
|
||||||
|
|||||||
Reference in New Issue
Block a user