feat: add tagged red thread connections
This commit is contained in:
@@ -99,6 +99,8 @@ 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. 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.
|
||||||
|
|
||||||
An open folder draws a pale red containment band to each expanded file. Each dated file independently projects a grey line to the temporal index. This allows the player to arrange files until those grey lines are vertical, close the folder, and later reopen the same arrangement.
|
An open folder draws a pale red containment band to each expanded file. Each dated file independently projects a grey line to the temporal index. This allows the player to arrange files until those grey lines are vertical, close the folder, and later reopen the same arrangement.
|
||||||
|
|||||||
@@ -64,11 +64,28 @@ test('a cloned Glass Harbor level can be solved without modifying its template',
|
|||||||
page.once('dialog', dialog => dialog.accept('The redirection memorandum was written after the crate had already moved.'))
|
page.once('dialog', dialog => dialog.accept('The redirection memorandum was written after the crate had already moved.'))
|
||||||
await waitForSave(page, () => page.getByRole('button', { name: 'NEW NOTE', exact: true }).click())
|
await waitForSave(page, () => page.getByRole('button', { name: 'NEW NOTE', exact: true }).click())
|
||||||
|
|
||||||
const mara = page.locator('.evidence-card.party').filter({ has: page.getByRole('heading', { name: 'Mara Voss', exact: true }) })
|
const elias = page.locator('.evidence-card.party').filter({ has: page.getByRole('heading', { name: 'Elias Vale', exact: true }) })
|
||||||
const movement = page.locator('.evidence-card.folder').filter({ hasText: 'MOVEMENT RECORDS' })
|
const movement = page.locator('.evidence-card.folder').filter({ hasText: 'MOVEMENT RECORDS' })
|
||||||
await mara.click()
|
await waitForSave(page, () => page.getByRole('button', { name: 'Zoom out', exact: true }).click())
|
||||||
await page.getByRole('button', { name: 'CONNECT', exact: true }).click()
|
await waitForSave(page, () => movement.getByRole('button', { name: 'OPEN', exact: true }).click())
|
||||||
await waitForSave(page, () => movement.click())
|
const manifest = page.locator('.source-file-widget.open').filter({ hasText: 'Carrier Dispatch Manifest' })
|
||||||
|
await elias.click()
|
||||||
|
await page.getByRole('button', { name: 'Red thread', exact: true }).click()
|
||||||
|
const boardBox = await page.locator('.board-viewport').boundingBox()
|
||||||
|
if (!boardBox) throw new Error('Board is not visible')
|
||||||
|
await page.mouse.move(boardBox.x + boardBox.width / 2, boardBox.y + boardBox.height / 2)
|
||||||
|
await expect(page.locator('.connections .thread-preview path')).toBeVisible()
|
||||||
|
const manifestBox = await manifest.boundingBox()
|
||||||
|
if (!manifestBox) throw new Error('Carrier Dispatch Manifest is not visible')
|
||||||
|
await page.mouse.move(manifestBox.x + manifestBox.width / 2, manifestBox.y + manifestBox.height / 2)
|
||||||
|
await manifest.click()
|
||||||
|
const threadEditor = page.locator('.thread-editor')
|
||||||
|
await expect(threadEditor.getByText('Elias Vale', { exact: true })).toBeVisible()
|
||||||
|
await expect(threadEditor.getByText('Carrier Dispatch Manifest · H&F 14', { exact: true })).toBeVisible()
|
||||||
|
await page.getByLabel('Thread tag').fill('Proof Elias is the driver')
|
||||||
|
await page.getByRole('slider', { name: 'Thread tightness', exact: true }).fill('85')
|
||||||
|
await waitForSave(page, () => page.getByRole('button', { name: 'ADD TAG & TIGHTEN', exact: true }).click())
|
||||||
|
await expect(page.locator('.connections g.tightening path')).toBeVisible()
|
||||||
|
|
||||||
await page.reload()
|
await page.reload()
|
||||||
await expect(page.locator('.evidence-card.party')).toHaveCount(6)
|
await expect(page.locator('.evidence-card.party')).toHaveCount(6)
|
||||||
@@ -77,6 +94,10 @@ test('a cloned Glass Harbor level can be solved without modifying its template',
|
|||||||
await expect(page.locator('.story-strip')).toContainText('CO-771 was diverted to Warehouse 3')
|
await expect(page.locator('.story-strip')).toContainText('CO-771 was diverted to Warehouse 3')
|
||||||
await expect(page.locator('.event-support-lines line')).toHaveCount(6)
|
await expect(page.locator('.event-support-lines line')).toHaveCount(6)
|
||||||
await expect(page.locator('.connections path')).toHaveCount(1)
|
await expect(page.locator('.connections path')).toHaveCount(1)
|
||||||
|
await expect(page.locator('.thread-tag')).toContainText('Proof Elias is the driver')
|
||||||
|
|
||||||
|
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 }))
|
||||||
|
|
||||||
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,6 @@
|
|||||||
|
ALTER TABLE osint.exhibit_connections
|
||||||
|
ADD COLUMN tightness SMALLINT NOT NULL DEFAULT 65
|
||||||
|
CHECK (tightness BETWEEN 0 AND 100);
|
||||||
|
|
||||||
|
COMMENT ON COLUMN osint.exhibit_connections.label IS 'Investigator-authored tag describing the meaning of this thread';
|
||||||
|
COMMENT ON COLUMN osint.exhibit_connections.tightness IS 'Visual thread tautness percentage from 0 (slack) to 100 (taut)';
|
||||||
@@ -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: noteId }]
|
state.connections = [{ id: randomUUID(), fromEvidenceId: folderId, toEvidenceId: documentId, label: 'Primary source', tightness: 85 }]
|
||||||
|
|
||||||
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,6 +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.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' }),
|
||||||
@@ -216,6 +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.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 }>(
|
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 FROM osint.exhibit_connections WHERE board_id=$1', [sourceBoardId])
|
'SELECT connection_type_id,from_exhibit_id,to_exhibit_id,label,tightness 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) VALUES ($1,$2,$3,$4,$5,$6)`,
|
(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])
|
[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])
|
||||||
|
|
||||||
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 }>(
|
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 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 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]),
|
||||||
@@ -155,7 +155,8 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
|
|||||||
const concepts: BriefConcept[] = conceptsResult.rows.map(row => ({ id: row.id, label: row.label, context: row.context_text,
|
const concepts: BriefConcept[] = conceptsResult.rows.map(row => ({ id: row.id, label: row.label, context: row.context_text,
|
||||||
...(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 })),
|
||||||
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,
|
||||||
@@ -281,9 +282,10 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
|
|||||||
}
|
}
|
||||||
for (const connection of state.connections) {
|
for (const connection of state.connections) {
|
||||||
requireUuid(connection.id, 'Connection id')
|
requireUuid(connection.id, 'Connection id')
|
||||||
if (!evidenceIds.has(connection.fromEvidenceId) || !evidenceIds.has(connection.toEvidenceId)) throw new Error('Connection references an unknown exhibit')
|
if (!allIds.includes(connection.fromEvidenceId) || !allIds.includes(connection.toEvidenceId) || connection.fromEvidenceId === connection.toEvidenceId) throw new Error('Connection references an unknown or identical exhibit')
|
||||||
await client.query(`INSERT INTO osint.exhibit_connections (id,board_id,connection_type_id,from_exhibit_id,to_exhibit_id)
|
const tightness = Math.max(0, Math.min(100, Math.round(Number(connection.tightness ?? 65))))
|
||||||
VALUES ($1,$2,'thread',$3,$4)`, [connection.id, level.board_id, connection.fromEvidenceId, connection.toEvidenceId])
|
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])
|
||||||
}
|
}
|
||||||
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(8)
|
expect(firstRun.filter(message => message.startsWith('apply '))).toHaveLength(9)
|
||||||
|
|
||||||
const client = new Client({ connectionString: testDatabaseUrl })
|
const client = new Client({ connectionString: testDatabaseUrl })
|
||||||
await client.connect()
|
await client.connect()
|
||||||
@@ -47,12 +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('8')
|
expect(ledger.rows[0].count).toBe('9')
|
||||||
|
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']))
|
||||||
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(8)
|
expect(secondRun.filter(message => message.startsWith('skip '))).toHaveLength(9)
|
||||||
expect(secondRun.some(message => message.startsWith('apply '))).toBe(false)
|
expect(secondRun.some(message => message.startsWith('apply '))).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+101
-18
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
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 { 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, Evidence, LevelBrief, OrganizationKind, PartyKind, SourceFileType, TimelineRange, WidgetRelation } from './types'
|
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, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel } from './boardDomain'
|
import { clampBoardZoom, containedIds, dateValue, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, relationPosition, threadCurve, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel } from './boardDomain'
|
||||||
import { documentWidget, exhibitWidget } from './exhibitRegistry'
|
import { documentWidget, exhibitWidget } from './exhibitRegistry'
|
||||||
|
|
||||||
const BOARD_W = 2400
|
const BOARD_W = 2400
|
||||||
@@ -39,6 +39,8 @@ export function App() {
|
|||||||
const [editingBrief, setEditingBrief] = useState(false)
|
const [editingBrief, setEditingBrief] = useState(false)
|
||||||
const [editingTimeline, setEditingTimeline] = useState(false)
|
const [editingTimeline, setEditingTimeline] = useState(false)
|
||||||
const [recentlyCreatedExhibitId, setRecentlyCreatedExhibitId] = useState<string | null>(null)
|
const [recentlyCreatedExhibitId, setRecentlyCreatedExhibitId] = useState<string | null>(null)
|
||||||
|
const [recentlyCreatedConnectionId, setRecentlyCreatedConnectionId] = useState<string | null>(null)
|
||||||
|
const [threadDraft, setThreadDraft] = useState<Connection | null>(null)
|
||||||
const saveTimer = useRef<number | undefined>(undefined)
|
const saveTimer = useRef<number | undefined>(undefined)
|
||||||
const boardRef = useRef<HTMLDivElement>(null)
|
const boardRef = useRef<HTMLDivElement>(null)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
@@ -76,6 +78,12 @@ export function App() {
|
|||||||
return () => window.clearTimeout(timer)
|
return () => window.clearTimeout(timer)
|
||||||
}, [recentlyCreatedExhibitId])
|
}, [recentlyCreatedExhibitId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!recentlyCreatedConnectionId) return
|
||||||
|
const timer = window.setTimeout(() => setRecentlyCreatedConnectionId(null), 1200)
|
||||||
|
return () => window.clearTimeout(timer)
|
||||||
|
}, [recentlyCreatedConnectionId])
|
||||||
|
|
||||||
const update = useCallback((fn: (state: CaseState) => CaseState) => {
|
const update = useCallback((fn: (state: CaseState) => CaseState) => {
|
||||||
setCaseState(current => {
|
setCaseState(current => {
|
||||||
if (!current) return current
|
if (!current) return current
|
||||||
@@ -163,10 +171,40 @@ export function App() {
|
|||||||
|
|
||||||
const handleCardClick = (id: string) => {
|
const handleCardClick = (id: string) => {
|
||||||
if (!linkFrom) { setSelected(current => current === id ? null : id); return }
|
if (!linkFrom) { setSelected(current => current === id ? null : id); return }
|
||||||
if (linkFrom !== id && caseState && !caseState.connections.some(c => (c.fromEvidenceId === linkFrom && c.toEvidenceId === id) || (c.fromEvidenceId === id && c.toEvidenceId === linkFrom))) {
|
completeThread(id)
|
||||||
update(s => ({ ...s, connections: [...s.connections, { id: uid('connection'), fromEvidenceId: linkFrom, toEvidenceId: id }] }))
|
|
||||||
}
|
}
|
||||||
setLinkFrom(null); setSelected(id)
|
|
||||||
|
const completeThread = (targetId: string) => {
|
||||||
|
if (!linkFrom || !caseState || linkFrom === targetId) return
|
||||||
|
const existing = caseState.connections.find(connection => (connection.fromEvidenceId === linkFrom && connection.toEvidenceId === targetId) || (connection.fromEvidenceId === targetId && connection.toEvidenceId === linkFrom))
|
||||||
|
if (existing) {
|
||||||
|
setLinkFrom(null); setThreadDraft(existing); setStatus('THREAD ALREADY EXISTS · EDITING TAG')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setThreadDraft({ id: uid('connection'), fromEvidenceId: linkFrom, toEvidenceId: targetId, tightness: 65 })
|
||||||
|
setLinkFrom(null)
|
||||||
|
if (caseState.evidence.some(item => item.id === targetId)) setSelected(targetId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveThread = (connection: Connection) => {
|
||||||
|
if (!caseState) return
|
||||||
|
const exists = caseState.connections.some(item => item.id === connection.id)
|
||||||
|
update(state => ({ ...state, connections: exists ? state.connections.map(item => item.id === connection.id ? connection : item) : [...state.connections, connection] }))
|
||||||
|
if (!exists) setRecentlyCreatedConnectionId(connection.id)
|
||||||
|
setThreadDraft(null)
|
||||||
|
setStatus(connection.label ? 'RED THREAD TAGGED' : 'RED THREAD TIGHTENED')
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeThread = (id: string) => {
|
||||||
|
update(state => ({ ...state, connections: state.connections.filter(connection => connection.id !== id) }))
|
||||||
|
setThreadDraft(null)
|
||||||
|
setStatus('RED THREAD REMOVED')
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleThreadTool = () => {
|
||||||
|
if (linkFrom) { setLinkFrom(null); setStatus('RED THREAD CANCELLED') }
|
||||||
|
else if (selected) { setLinkFrom(selected); setStatus('RED THREAD READY · SELECT TARGET') }
|
||||||
|
setBoardTool('move')
|
||||||
}
|
}
|
||||||
|
|
||||||
const reset = async () => {
|
const reset = async () => {
|
||||||
@@ -274,7 +312,7 @@ export function App() {
|
|||||||
|
|
||||||
<div className="board-shell" onDragEnter={e => { if (e.dataTransfer.types.includes('Files') && requestedEditMode && caseState.editingAllowed) { e.preventDefault(); setDraggingFiles(true) } }} onDragOver={e => { if (requestedEditMode && caseState.editingAllowed) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' } }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDraggingFiles(false) }} onDrop={e => { e.preventDefault(); if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files) }}>
|
<div className="board-shell" onDragEnter={e => { if (e.dataTransfer.types.includes('Files') && requestedEditMode && caseState.editingAllowed) { e.preventDefault(); setDraggingFiles(true) } }} onDragOver={e => { if (requestedEditMode && caseState.editingAllowed) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' } }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDraggingFiles(false) }} onDrop={e => { e.preventDefault(); if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files) }}>
|
||||||
<div className="case-heading"><div><small>{requestedEditMode && caseState.editingAllowed ? 'AUTHORING LEVEL' : 'ACTIVE INVESTIGATION'}</small><h1>{caseState.title}</h1><p>{caseState.subtitle || caseState.id.toUpperCase()}</p></div><div className="case-number">{requestedEditMode && caseState.editingAllowed ? 'DRAFT' : 'CASE'}<br/><b>{caseState.levelStatus?.toUpperCase() || 'ACTIVE'}</b></div></div>
|
<div className="case-heading"><div><small>{requestedEditMode && caseState.editingAllowed ? 'AUTHORING LEVEL' : 'ACTIVE INVESTIGATION'}</small><h1>{caseState.title}</h1><p>{caseState.subtitle || caseState.id.toUpperCase()}</p></div><div className="case-number">{requestedEditMode && caseState.editingAllowed ? 'DRAFT' : 'CASE'}<br/><b>{caseState.levelStatus?.toUpperCase() || 'ACTIVE'}</b></div></div>
|
||||||
<Board state={caseState} selected={selected} linkFrom={linkFrom} recentlyCreatedExhibitId={recentlyCreatedExhibitId} tool={boardTool} boardRef={boardRef} update={update} onCardClick={handleCardClick} onOpenSource={id => setOpenDoc(caseState.documents.find(d => d.id === id) || null)} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} />
|
<Board state={caseState} selected={selected} linkFrom={linkFrom} recentlyCreatedExhibitId={recentlyCreatedExhibitId} recentlyCreatedConnectionId={recentlyCreatedConnectionId} tool={boardTool} boardRef={boardRef} update={update} onCardClick={handleCardClick} onConnectionTarget={completeThread} onEditConnection={connection => setThreadDraft(connection)} onOpenSource={id => setOpenDoc(caseState.documents.find(d => d.id === id) || null)} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} />
|
||||||
{briefOpen && <BriefPanel
|
{briefOpen && <BriefPanel
|
||||||
brief={caseState.brief}
|
brief={caseState.brief}
|
||||||
parties={caseState.evidence.filter(item => item.type === 'party')}
|
parties={caseState.evidence.filter(item => item.type === 'party')}
|
||||||
@@ -294,7 +332,7 @@ export function App() {
|
|||||||
<span />
|
<span />
|
||||||
<button onClick={addNote}><NotebookPen size={17}/> NEW NOTE</button>
|
<button onClick={addNote}><NotebookPen size={17}/> NEW NOTE</button>
|
||||||
<button onClick={addEvent}><CalendarClock size={17}/> NEW EVENT</button>
|
<button onClick={addEvent}><CalendarClock size={17}/> NEW EVENT</button>
|
||||||
<button className={linkFrom ? 'active' : ''} disabled={!selected} onClick={() => setLinkFrom(linkFrom ? null : selected)}><Link2 size={17}/> {linkFrom ? 'SELECT TARGET' : 'CONNECT'}</button>
|
<button className={`thread-tool ${linkFrom ? 'active' : ''}`} aria-label="Red thread" title={linkFrom ? 'Cancel red thread' : selected ? 'Connect selected exhibit with red thread' : 'Select an exhibit first'} disabled={!selected} onClick={toggleThreadTool}><Link2 size={18}/></button>
|
||||||
<span />
|
<span />
|
||||||
<button aria-label="Zoom out" onClick={() => update(s => ({ ...s, viewport: { ...s.viewport, zoom: clampBoardZoom(s.viewport.zoom - .1) } }))}><ZoomOut size={18}/></button>
|
<button aria-label="Zoom out" onClick={() => update(s => ({ ...s, viewport: { ...s.viewport, zoom: clampBoardZoom(s.viewport.zoom - .1) } }))}><ZoomOut size={18}/></button>
|
||||||
<b>{Math.round(caseState.viewport.zoom * 100)}%</b>
|
<b>{Math.round(caseState.viewport.zoom * 100)}%</b>
|
||||||
@@ -349,6 +387,16 @@ export function App() {
|
|||||||
setStatus(timelineRange ? 'TIMELINE RANGE UPDATED' : 'TIMELINE RANGE AUTOMATIC')
|
setStatus(timelineRange ? 'TIMELINE RANGE UPDATED' : 'TIMELINE RANGE AUTOMATIC')
|
||||||
}}
|
}}
|
||||||
/>}
|
/>}
|
||||||
|
{threadDraft && <ThreadEditor
|
||||||
|
key={threadDraft.id}
|
||||||
|
connection={threadDraft}
|
||||||
|
sourceName={caseState.evidence.find(item => item.id === threadDraft.fromEvidenceId)?.title || caseState.documents.find(item => item.id === threadDraft.fromEvidenceId)?.title || 'Exhibit'}
|
||||||
|
targetName={caseState.evidence.find(item => item.id === threadDraft.toEvidenceId)?.title || caseState.documents.find(item => item.id === threadDraft.toEvidenceId)?.title || 'Exhibit'}
|
||||||
|
isNew={!caseState.connections.some(item => item.id === threadDraft.id)}
|
||||||
|
onClose={() => setThreadDraft(null)}
|
||||||
|
onSave={saveThread}
|
||||||
|
onRemove={() => removeThread(threadDraft.id)}
|
||||||
|
/>}
|
||||||
{helpOpen && <Help onClose={() => setHelpOpen(false)}/>}
|
{helpOpen && <Help onClose={() => setHelpOpen(false)}/>}
|
||||||
</main>
|
</main>
|
||||||
}
|
}
|
||||||
@@ -368,13 +416,23 @@ function EmptyArchive({ canEdit, onCreated }: { canEdit: boolean; onCreated: (le
|
|||||||
return <main className="empty-archive"><div className="seal">GU</div><small>GLITCH UNIVERSITY LEVEL ARCHIVE</small><h1>No investigations found.</h1><p>The database is ready, but no authored level exists yet.</p>{canEdit ? <button disabled={creating} onClick={createLevel}><Plus size={17}/>{creating ? 'CREATING…' : 'CREATE FIRST LEVEL'}</button> : <p className="hint">Add <code>?edit=1</code> and enable level editing on the server to begin authoring.</p>}</main>
|
return <main className="empty-archive"><div className="seal">GU</div><small>GLITCH UNIVERSITY LEVEL ARCHIVE</small><h1>No investigations found.</h1><p>The database is ready, but no authored level exists yet.</p>{canEdit ? <button disabled={creating} onClick={createLevel}><Plus size={17}/>{creating ? 'CREATING…' : 'CREATE FIRST LEVEL'}</button> : <p className="hint">Add <code>?edit=1</code> and enable level editing on the server to begin authoring.</p>}</main>
|
||||||
}
|
}
|
||||||
|
|
||||||
function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, tool, boardRef, update, onCardClick, onOpenSource, onEditFolder, onEditFile, onEditEvent, onEditParty }: { state: CaseState; selected: string | null; linkFrom: string | null; recentlyCreatedExhibitId: string | null; tool: 'move' | 'hand'; boardRef: React.RefObject<HTMLDivElement | null>; update: (fn: (s: CaseState) => CaseState) => void; onCardClick: (id: string) => void; onOpenSource: (id: string) => void; onEditFolder: (id: string) => void; onEditFile: (id: string) => void; onEditEvent: (id: string) => void; onEditParty: (id: string) => void }) {
|
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<HTMLDivElement | null>; 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'; id?: string; startX: number; startY: number; originX: number; originY: number; moved?: boolean } | null>(null)
|
||||||
const suppressClick = useRef(false)
|
const suppressClick = useRef(false)
|
||||||
const touchPoints = useRef(new Map<number, { x: number; y: number }>())
|
const touchPoints = useRef(new Map<number, { x: number; y: number }>())
|
||||||
const pinchDistance = useRef<number | null>(null)
|
const pinchDistance = useRef<number | null>(null)
|
||||||
|
const [threadPointer, setThreadPointer] = useState<{ x: number; y: number } | null>(null)
|
||||||
const byId = useMemo(() => new Map(state.evidence.map(e => [e.id, e])), [state.evidence])
|
const byId = useMemo(() => new Map(state.evidence.map(e => [e.id, e])), [state.evidence])
|
||||||
const containmentRelations = state.relations.filter(relation => relation.type === 'contains')
|
const containmentRelations = state.relations.filter(relation => relation.type === 'contains')
|
||||||
|
const pointForId = (id: string) => {
|
||||||
|
const evidence = byId.get(id)
|
||||||
|
if (evidence) return connectionPoint(evidence)
|
||||||
|
const relation = containmentRelations.find(item => item.toWidgetId === id)
|
||||||
|
const folder = relation ? byId.get(relation.fromWidgetId) : undefined
|
||||||
|
if (!relation || !folder) return undefined
|
||||||
|
const position = relationPosition(state, relation)
|
||||||
|
return folderIsOpen(folder) ? { x: position.x + 87, y: position.y + 72 } : connectionPoint(folder)
|
||||||
|
}
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const board = boardRef.current
|
const board = boardRef.current
|
||||||
if (!board) return
|
if (!board) return
|
||||||
@@ -405,7 +463,14 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, tool, boar
|
|||||||
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 }
|
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. */ }
|
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()
|
||||||
|
setThreadPointer({ x: (event.clientX - bounds.left - state.viewport.x) / state.viewport.zoom, y: (event.clientY - bounds.top - state.viewport.y) / state.viewport.zoom })
|
||||||
|
}
|
||||||
|
}
|
||||||
const pointerMove = (event: React.PointerEvent) => {
|
const pointerMove = (event: React.PointerEvent) => {
|
||||||
|
trackThreadPointer(event)
|
||||||
if (event.pointerType === 'touch' && touchPoints.current.has(event.pointerId)) {
|
if (event.pointerType === 'touch' && touchPoints.current.has(event.pointerId)) {
|
||||||
touchPoints.current.set(event.pointerId, { x: event.clientX, y: event.clientY })
|
touchPoints.current.set(event.pointerId, { x: event.clientX, y: event.clientY })
|
||||||
if (touchPoints.current.size >= 2) {
|
if (touchPoints.current.size >= 2) {
|
||||||
@@ -435,14 +500,17 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, tool, boar
|
|||||||
drag.current = null
|
drag.current = 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 toggleFolder = (id: string) => update(s => ({ ...s, evidence: s.evidence.map(widget => widget.id === id ? { ...widget, config: { ...(widget.config || {}), open: !folderIsOpen(widget) } } : widget) }))
|
||||||
return <div className={`board-viewport tool-${tool}`} ref={boardRef}
|
const previewOrigin = linkFrom ? pointForId(linkFrom) : undefined
|
||||||
|
return <div className={`board-viewport tool-${tool} ${linkFrom ? 'threading' : ''}`} ref={boardRef}
|
||||||
onPointerDown={e => { if (e.pointerType === 'touch' || tool === 'hand' || e.button === 1) { e.preventDefault(); pointerDown(e) } }}
|
onPointerDown={e => { if (e.pointerType === 'touch' || tool === 'hand' || e.button === 1) { e.preventDefault(); pointerDown(e) } }}
|
||||||
onPointerMove={pointerMove} onPointerUp={finishDrag} onPointerCancel={finishDrag} onAuxClick={e => { if (e.button === 1) e.preventDefault() }}>
|
onPointerMoveCapture={trackThreadPointer} onPointerMove={pointerMove} onPointerLeave={() => setThreadPointer(null)} onPointerUp={finishDrag} onPointerCancel={finishDrag} onAuxClick={e => { if (e.button === 1) e.preventDefault() }}>
|
||||||
<div className="board" style={{ width: BOARD_W, height: BOARD_H, transform: `translate(${state.viewport.x}px, ${state.viewport.y}px) scale(${state.viewport.zoom})` }}>
|
<div className="board" style={{ width: BOARD_W, height: BOARD_H, transform: `translate(${state.viewport.x}px, ${state.viewport.y}px) scale(${state.viewport.zoom})` }}>
|
||||||
<div className="board-stamp">AUTHORIZED CITIZEN SCIENTIST WORKSTATION <span>GU-NET / 04</span></div>
|
<div className="board-stamp">AUTHORIZED CITIZEN SCIENTIST WORKSTATION <span>GU-NET / 04</span></div>
|
||||||
<svg className="connections" width={BOARD_W} height={BOARD_H}>
|
<svg className="connections" width={BOARD_W} height={BOARD_H}>
|
||||||
{state.connections.map(c => { const a = byId.get(c.fromEvidenceId), b = byId.get(c.toEvidenceId); if (!a || !b) return null; const p1 = connectionPoint(a), p2 = connectionPoint(b); return <g key={c.id}><path d={`M ${p1.x} ${p1.y} C ${(p1.x+p2.x)/2} ${p1.y}, ${(p1.x+p2.x)/2} ${p2.y}, ${p2.x} ${p2.y}`}/><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>}
|
||||||
</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; return <button key={`tag:${connection.id}`} className={`thread-tag ${connection.label ? 'labelled' : ''}`} style={{ left: midpoint.x, top: midpoint.y }} title="Edit thread tag and tightness" onPointerDown={event => event.stopPropagation()} onClick={event => { event.stopPropagation(); onEditConnection(connection) }}><i/>{connection.label && <span>{connection.label}</span>}</button> })}
|
||||||
<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)
|
||||||
@@ -474,20 +542,20 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, tool, boar
|
|||||||
<svg className="folder-bands" width={BOARD_W} height={BOARD_H}>
|
<svg className="folder-bands" width={BOARD_W} height={BOARD_H}>
|
||||||
{containmentRelations.map(relation => { const folder = byId.get(relation.fromWidgetId), document = state.documents.find(candidate => candidate.id === relation.toWidgetId); if (!folder || !document) return null; const open = folderIsOpen(folder), position = relationPosition(state, relation), origin = { x: folder.x + folder.width / 2, y: folder.y + 78 }; return <line className={open ? 'open' : 'closed'} key={relation.id} x1={origin.x} y1={origin.y} x2={open ? position.x + 87 : origin.x} y2={open ? position.y + 72 : origin.y}/> })}
|
{containmentRelations.map(relation => { const folder = byId.get(relation.fromWidgetId), document = state.documents.find(candidate => candidate.id === relation.toWidgetId); if (!folder || !document) return null; const open = folderIsOpen(folder), position = relationPosition(state, relation), origin = { x: folder.x + folder.width / 2, y: folder.y + 78 }; return <line className={open ? 'open' : 'closed'} key={relation.id} x1={origin.x} y1={origin.y} x2={open ? position.x + 87 : origin.x} y2={open ? position.y + 72 : origin.y}/> })}
|
||||||
</svg>
|
</svg>
|
||||||
{state.evidence.map((ev, i) => { const containedDocuments = containedIds(state, ev.id).flatMap(id => { const document = state.documents.find(candidate => candidate.id === id); return document ? [document] : [] }); const definition = exhibitWidget(ev.type); const Widget = definition.Component; return <article key={ev.id} data-temporal-id={`widget:${ev.id}`} className={`evidence-card ${definition.visualType} ${selected === ev.id ? 'selected' : ''} ${linkFrom === ev.id ? 'linking' : ''} ${recentlyCreatedExhibitId === ev.id ? 'arriving' : ''}`} style={{ left: ev.x, top: ev.y, width: ev.width, rotate: `${(i % 3 - 1) * .45}deg` }}
|
{state.evidence.map((ev, i) => { const containedDocuments = containedIds(state, ev.id).flatMap(id => { const document = state.documents.find(candidate => candidate.id === id); return document ? [document] : [] }); const definition = exhibitWidget(ev.type); const Widget = definition.Component; return <article key={ev.id} data-temporal-id={`widget:${ev.id}`} className={`evidence-card ${definition.visualType} ${selected === ev.id ? 'selected' : ''} ${linkFrom === ev.id ? 'linking' : ''} ${linkFrom && linkFrom !== ev.id ? 'thread-target' : ''} ${recentlyCreatedExhibitId === ev.id ? 'arriving' : ''}`} style={{ left: ev.x, top: ev.y, width: ev.width, rotate: `${(i % 3 - 1) * .45}deg` }}
|
||||||
onPointerDown={e => { e.stopPropagation(); if (tool === 'hand' || e.button === 1) { e.preventDefault(); pointerDown(e) } else if (e.button === 0) pointerDown(e, { kind: 'widget', id: ev.id }) }}
|
onPointerDown={e => { e.stopPropagation(); if (linkFrom && e.button === 0) return; if (tool === 'hand' || e.button === 1) { e.preventDefault(); pointerDown(e) } else if (e.button === 0) pointerDown(e, { kind: 'widget', id: ev.id }) }}
|
||||||
onPointerMove={e => { e.stopPropagation(); pointerMove(e) }} onPointerUp={e => { e.stopPropagation(); finishDrag(e) }} onPointerCancel={e => { e.stopPropagation(); finishDrag(e) }}
|
onPointerMove={e => { e.stopPropagation(); pointerMove(e) }} onPointerUp={e => { e.stopPropagation(); finishDrag(e) }} onPointerCancel={e => { e.stopPropagation(); finishDrag(e) }}
|
||||||
onAuxClick={e => { if (e.button === 1) e.preventDefault() }} onClick={e => { e.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (tool === 'move') onCardClick(ev.id) }}>
|
onAuxClick={e => { if (e.button === 1) e.preventDefault() }} onClick={e => { e.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (tool === 'move') onCardClick(ev.id) }}>
|
||||||
<header><span>{definition.heading(ev, containedDocuments)}</span><i>{String(i + 1).padStart(3, '0')}</i></header>
|
<header><span>{definition.heading(ev, containedDocuments)}</span><i>{String(i + 1).padStart(3, '0')}</i></header>
|
||||||
<Widget exhibit={ev} documents={containedDocuments} onOpenSource={onOpenSource} onToggleFolder={toggleFolder} onEditFolder={onEditFolder} onEditEvent={onEditEvent} onEditParty={onEditParty}/>
|
<Widget exhibit={ev} documents={containedDocuments} onOpenSource={onOpenSource} onToggleFolder={toggleFolder} onEditFolder={onEditFolder} onEditEvent={onEditEvent} onEditParty={onEditParty}/>
|
||||||
</article>})}
|
</article>})}
|
||||||
{containmentRelations.map(relation => { const folder = byId.get(relation.fromWidgetId), document = state.documents.find(candidate => candidate.id === relation.toWidgetId); if (!folder || !document) return null; const open = folderIsOpen(folder), target = relationPosition(state, relation); const left = open ? target.x : folder.x + folder.width / 2 - 87, top = open ? target.y : folder.y + 45; const definition = documentWidget(document.fileType); const Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; return <article key={relation.id} data-temporal-id={`file:${relation.id}`} className={`source-file-widget ${open ? 'open' : 'closed'} file-type-${document.fileType}`} style={{ left, top }}
|
{containmentRelations.map(relation => { const folder = byId.get(relation.fromWidgetId), document = state.documents.find(candidate => candidate.id === relation.toWidgetId); if (!folder || !document) return null; const open = folderIsOpen(folder), target = relationPosition(state, relation); const left = open ? target.x : folder.x + folder.width / 2 - 87, top = open ? target.y : folder.y + 45; const definition = documentWidget(document.fileType); const Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; return <article key={relation.id} data-temporal-id={`file:${relation.id}`} className={`source-file-widget ${open ? 'open' : 'closed'} file-type-${document.fileType} ${linkFrom && linkFrom !== document.id ? 'thread-target' : ''}`} style={{ left, top }}
|
||||||
onPointerDown={event => { event.stopPropagation(); if (tool === 'hand' || event.button === 1) { event.preventDefault(); pointerDown(event) } else if (open && event.button === 0) pointerDown(event, { kind: 'relation', id: relation.id }) }}
|
onPointerDown={event => { event.stopPropagation(); if (linkFrom && event.button === 0) return; if (tool === 'hand' || event.button === 1) { event.preventDefault(); pointerDown(event) } else if (open && event.button === 0) pointerDown(event, { kind: 'relation', id: relation.id }) }}
|
||||||
onPointerMove={event => { event.stopPropagation(); pointerMove(event) }} onPointerUp={event => { event.stopPropagation(); finishDrag(event) }} onPointerCancel={event => { event.stopPropagation(); finishDrag(event) }} onDoubleClick={() => open && onOpenSource(document.id)}>
|
onPointerMove={event => { event.stopPropagation(); pointerMove(event) }} onPointerUp={event => { event.stopPropagation(); finishDrag(event) }} onPointerCancel={event => { event.stopPropagation(); finishDrag(event) }} onClick={event => { event.stopPropagation(); if (open && linkFrom) onConnectionTarget(document.id) }} onDoubleClick={() => open && !linkFrom && onOpenSource(document.id)}>
|
||||||
<header><span>{definition.label.toUpperCase()}</span><i>{String((relation.sortOrder || 0) + 1).padStart(2, '0')}</i></header>
|
<header><span>{definition.label.toUpperCase()}</span><i>{String((relation.sortOrder || 0) + 1).padStart(2, '0')}</i></header>
|
||||||
<div className="source-file-preview"><Preview document={document} source={source}/></div>
|
<div className="source-file-preview"><Preview document={document} source={source}/></div>
|
||||||
<strong>{document.title}</strong><time>{(document.publishedAt || document.date)?.slice(0, 10) || 'UNDATED'}</time>
|
<strong>{document.title}</strong><time>{(document.publishedAt || document.date)?.slice(0, 10) || 'UNDATED'}</time>
|
||||||
<div className="source-file-actions"><button onClick={() => onOpenSource(document.id)}><BookOpen size={12}/> OPEN</button><button onClick={() => onEditFile(document.id)}><Pencil size={12}/> METADATA</button></div>
|
<div className="source-file-actions"><button onClick={event => { event.stopPropagation(); onOpenSource(document.id) }}><BookOpen size={12}/> OPEN</button><button onClick={event => { event.stopPropagation(); onEditFile(document.id) }}><Pencil size={12}/> METADATA</button></div>
|
||||||
</article> })}
|
</article> })}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -559,6 +627,21 @@ function TimelineRangeEditor({ range, dates, onClose, onSave }: { range?: Timeli
|
|||||||
</form></div>
|
</form></div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 })
|
||||||
|
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>
|
||||||
|
<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>
|
||||||
|
<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 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>
|
||||||
|
</form></div>
|
||||||
|
}
|
||||||
|
|
||||||
function BriefPanel({ brief, parties, recentlyCreatedExhibitId, canEdit, onClose, onEdit, onClassify, onLocate, onEditParty }: { brief: LevelBrief; parties: Evidence[]; recentlyCreatedExhibitId: string | null; canEdit: boolean; onClose: () => void; onEdit: () => void; onClassify: (id: string, kind: PartyKind) => void; onLocate: (id: string) => void; onEditParty: (id: string) => void }) {
|
function BriefPanel({ brief, parties, recentlyCreatedExhibitId, canEdit, onClose, onEdit, onClassify, onLocate, onEditParty }: { brief: LevelBrief; parties: Evidence[]; recentlyCreatedExhibitId: string | null; canEdit: boolean; onClose: () => void; onEdit: () => void; onClassify: (id: string, kind: PartyKind) => void; onLocate: (id: string) => void; onEditParty: (id: string) => void }) {
|
||||||
const partyById = new Map(parties.map(party => [party.id, party]))
|
const partyById = new Map(parties.map(party => [party.id, party]))
|
||||||
const unresolved = brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length
|
const unresolved = brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length
|
||||||
@@ -719,4 +802,4 @@ function DocumentAsset({ doc }: { doc: CaseDocument }) {
|
|||||||
return <Asset document={doc} source={source}/>
|
return <Asset document={doc} source={source}/>
|
||||||
}
|
}
|
||||||
|
|
||||||
function Help({ onClose }: { onClose: () => void }) { return <div className="modal-shade"><section className="window help"><header><CircleHelp size={16}/><b>Field Manual</b><span/><button onClick={onClose}><X size={14}/></button></header><div><small>GU-NET QUICK START</small><h2>Reconstruct what happened.</h2><ol><li>Open a case document.</li><li>Extract the highlighted clue.</li><li>Drag evidence into meaningful groups.</li><li>Select a card, choose Connect, then select its target.</li><li>Use dated markers to move through the case.</li></ol><p>The system will not announce your conclusion. Make it visible.</p><button className="primary" onClick={onClose}>BEGIN INVESTIGATION</button></div></section></div> }
|
function Help({ onClose }: { onClose: () => void }) { return <div className="modal-shade"><section className="window help"><header><CircleHelp size={16}/><b>Field Manual</b><span/><button onClick={onClose}><X size={14}/></button></header><div><small>GU-NET QUICK START</small><h2>Reconstruct what happened.</h2><ol><li>Open a case document.</li><li>Extract the highlighted clue.</li><li>Drag evidence into meaningful groups.</li><li>Select an exhibit, choose the red-thread tool, then select its target.</li><li>Tag the thread with the claim it represents.</li><li>Use dated markers to move through the case.</li></ol><p>The system will not announce your conclusion. Make it visible.</p><button className="primary" onClick={onClose}>BEGIN INVESTIGATION</button></div></section></div> }
|
||||||
|
|||||||
@@ -11,12 +11,27 @@ import {
|
|||||||
normalizeCase,
|
normalizeCase,
|
||||||
panViewport,
|
panViewport,
|
||||||
relationPosition,
|
relationPosition,
|
||||||
|
threadCurve,
|
||||||
timelinePositionPercent,
|
timelinePositionPercent,
|
||||||
timelineRange,
|
timelineRange,
|
||||||
zoomFromWheel,
|
zoomFromWheel,
|
||||||
zoomFromPinch,
|
zoomFromPinch,
|
||||||
} from './boardDomain'
|
} from './boardDomain'
|
||||||
|
|
||||||
|
describe('red thread geometry', () => {
|
||||||
|
it('turns tightness into a taut or visibly sagging bezier curve', () => {
|
||||||
|
const taut = threadCurve({ x: 0, y: 0 }, { x: 120, y: 60 }, 100)
|
||||||
|
const slack = threadCurve({ x: 0, y: 0 }, { x: 120, y: 60 }, 0)
|
||||||
|
expect(taut.midpoint).toEqual({ x: 60, y: 30 })
|
||||||
|
expect(slack.midpoint.y).toBeGreaterThan(taut.midpoint.y)
|
||||||
|
expect(slack.path).toMatch(/^M 0 0 C /)
|
||||||
|
})
|
||||||
|
|
||||||
|
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))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const folder: Evidence = {
|
const folder: Evidence = {
|
||||||
id: 'folder-1',
|
id: 'folder-1',
|
||||||
type: 'folder',
|
type: 'folder',
|
||||||
|
|||||||
@@ -1,5 +1,20 @@
|
|||||||
import type { CaseState, Evidence, TimelineRange, Viewport, WidgetRelation } from './types'
|
import type { CaseState, Evidence, TimelineRange, Viewport, WidgetRelation } from './types'
|
||||||
|
|
||||||
|
export interface BoardPoint { x: number; y: number }
|
||||||
|
|
||||||
|
export function threadCurve(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 { path: `M ${from.x} ${from.y} C ${c1.x} ${c1.y}, ${c2.x} ${c2.y}, ${to.x} ${to.y}`, midpoint }
|
||||||
|
}
|
||||||
|
|
||||||
export const MIN_BOARD_ZOOM = 0.45
|
export const MIN_BOARD_ZOOM = 0.45
|
||||||
export const MAX_BOARD_ZOOM = 1.5
|
export const MAX_BOARD_ZOOM = 1.5
|
||||||
|
|
||||||
|
|||||||
+14
-1
@@ -47,12 +47,21 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
|||||||
.board-viewport { position: absolute; inset: 0; overflow: hidden; touch-action: none; overscroll-behavior: contain; cursor: default; background-image: radial-gradient(#49615a55 1px, transparent 1px), linear-gradient(90deg, #18302a33 1px, transparent 1px), linear-gradient(#18302a33 1px, transparent 1px); background-size: 20px 20px, 100px 100px, 100px 100px; }
|
.board-viewport { position: absolute; inset: 0; overflow: hidden; touch-action: none; overscroll-behavior: contain; cursor: default; background-image: radial-gradient(#49615a55 1px, transparent 1px), linear-gradient(90deg, #18302a33 1px, transparent 1px), linear-gradient(#18302a33 1px, transparent 1px); background-size: 20px 20px, 100px 100px, 100px 100px; }
|
||||||
.board-viewport.tool-hand, .board-viewport.tool-hand .evidence-card, .board-viewport.tool-hand .source-file-widget { cursor: grab; }
|
.board-viewport.tool-hand, .board-viewport.tool-hand .evidence-card, .board-viewport.tool-hand .source-file-widget { cursor: grab; }
|
||||||
.board-viewport.tool-hand:active, .board-viewport.tool-hand .evidence-card:active, .board-viewport.tool-hand .source-file-widget:active { cursor: grabbing; }
|
.board-viewport.tool-hand:active, .board-viewport.tool-hand .evidence-card:active, .board-viewport.tool-hand .source-file-widget:active { cursor: grabbing; }
|
||||||
|
.board-viewport.threading, .board-viewport.threading .evidence-card, .board-viewport.threading .source-file-widget { cursor: crosshair; }
|
||||||
.board { transform-origin: 0 0; position: absolute; background: linear-gradient(110deg, #0c211d33, #122b251f); }
|
.board { transform-origin: 0 0; position: absolute; background: linear-gradient(110deg, #0c211d33, #122b251f); }
|
||||||
.board-stamp { position: absolute; left: 1480px; top: 240px; color: #6e807a2e; border: 2px solid #6e807a20; padding: 9px 13px; transform: rotate(-4deg); font: 600 11px IBM Plex Mono; }
|
.board-stamp { position: absolute; left: 1480px; top: 240px; color: #6e807a2e; border: 2px solid #6e807a20; padding: 9px 13px; transform: rotate(-4deg); font: 600 11px IBM Plex Mono; }
|
||||||
.board-stamp span { display: block; text-align: center; font-size: 8px; margin-top: 4px; }
|
.board-stamp span { display: block; text-align: center; font-size: 8px; margin-top: 4px; }
|
||||||
.connections { position: absolute; inset: 0; overflow: visible; pointer-events: none; }
|
.connections { position: absolute; inset: 0; overflow: visible; pointer-events: none; }
|
||||||
.connections path { stroke: #8f2828; stroke-width: 3; fill: none; filter: drop-shadow(1px 2px 0 #020907aa); }
|
.connections path { stroke: #8f2828; stroke-width: 3; fill: none; filter: drop-shadow(1px 2px 0 #020907aa); }
|
||||||
.connections circle { fill: #b33a32; stroke: #581916; stroke-width: 2; }
|
.connections circle { fill: #b33a32; stroke: #581916; stroke-width: 2; }
|
||||||
|
.connections .thread-preview path { stroke: #bc3b35; stroke-width: 2.5; stroke-dasharray: 8 6; opacity: .82; }
|
||||||
|
.connections .thread-preview circle:last-child { fill: #0d211c; stroke: #c84a43; }
|
||||||
|
.connections g.tightening path { stroke-dasharray: 1400; animation: thread-tighten .9s cubic-bezier(.15,.75,.22,1) both; }
|
||||||
|
@keyframes thread-tighten { 0% { stroke-dashoffset: 1400; filter: drop-shadow(0 0 7px #d85a50); } 70% { stroke-dashoffset: 0; stroke-width: 4; } 100% { stroke-dashoffset: 0; stroke-width: 3; } }
|
||||||
|
.thread-tag { position: absolute; z-index: 7; transform: translate(-50%, -50%); display: flex; align-items: center; gap: 5px; max-width: 190px; border: 0; background: transparent; color: #241d17; cursor: pointer; }
|
||||||
|
.thread-tag 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 span { 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:hover i, .thread-tag:focus-visible i { background: #e07158; box-shadow: 0 0 0 4px #b23e3544; }
|
||||||
.event-support-lines { position: absolute; inset: 0; overflow: visible; pointer-events: none; }
|
.event-support-lines { position: absolute; inset: 0; overflow: visible; pointer-events: none; }
|
||||||
.event-support-lines line { stroke: #d3a05c; stroke-width: 2; stroke-dasharray: 7 6; opacity: .58; filter: drop-shadow(1px 1px 0 #020907); }
|
.event-support-lines line { stroke: #d3a05c; stroke-width: 2; stroke-dasharray: 7 6; opacity: .58; filter: drop-shadow(1px 1px 0 #020907); }
|
||||||
.party-association-lines { position: absolute; inset: 0; overflow: visible; pointer-events: none; }
|
.party-association-lines { position: absolute; inset: 0; overflow: visible; pointer-events: none; }
|
||||||
@@ -64,6 +73,7 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
|||||||
.evidence-card::after { content: ''; position: absolute; left: 9px; right: 9px; bottom: -6px; height: 5px; background: #80847b; 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%); }
|
.evidence-card::after { content: ''; position: absolute; left: 9px; right: 9px; bottom: -6px; height: 5px; background: #80847b; 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%); }
|
||||||
.evidence-card.selected { outline: 2px solid #e49a4a; outline-offset: 5px; }
|
.evidence-card.selected { outline: 2px solid #e49a4a; outline-offset: 5px; }
|
||||||
.evidence-card.linking { outline: 2px dashed #e49a4a; outline-offset: 7px; }
|
.evidence-card.linking { outline: 2px dashed #e49a4a; outline-offset: 7px; }
|
||||||
|
.evidence-card.thread-target:hover, .source-file-widget.thread-target:hover { outline: 2px dashed #b8443e; outline-offset: 6px; }
|
||||||
.evidence-card.arriving { z-index: 6; animation: exhibit-arrival 1.15s cubic-bezier(.18,.85,.22,1) both; }
|
.evidence-card.arriving { z-index: 6; animation: exhibit-arrival 1.15s cubic-bezier(.18,.85,.22,1) both; }
|
||||||
@keyframes exhibit-arrival { 0% { opacity: 0; scale: .72; translate: 0 -24px; filter: brightness(1.7); box-shadow: 0 0 0 0 #eda85b00; } 45% { opacity: 1; scale: 1.035; translate: 0 2px; box-shadow: 0 0 0 12px #eda85b55, 7px 9px 0 #020b0980; } 100% { opacity: 1; scale: 1; translate: 0 0; filter: brightness(1); box-shadow: 7px 9px 0 #020b0980, 0 0 0 1px #45524d; } }
|
@keyframes exhibit-arrival { 0% { opacity: 0; scale: .72; translate: 0 -24px; filter: brightness(1.7); box-shadow: 0 0 0 0 #eda85b00; } 45% { opacity: 1; scale: 1.035; translate: 0 2px; box-shadow: 0 0 0 12px #eda85b55, 7px 9px 0 #020b0980; } 100% { opacity: 1; scale: 1; translate: 0 0; filter: brightness(1); box-shadow: 7px 9px 0 #020b0980, 0 0 0 1px #45524d; } }
|
||||||
.evidence-card header { border-bottom: 1px solid #989e94; display: flex; justify-content: space-between; padding-bottom: 6px; font: 600 8px IBM Plex Mono; letter-spacing: .12em; color: #5d6763; }
|
.evidence-card header { border-bottom: 1px solid #989e94; display: flex; justify-content: space-between; padding-bottom: 6px; font: 600 8px IBM Plex Mono; letter-spacing: .12em; color: #5d6763; }
|
||||||
@@ -123,6 +133,8 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
|||||||
.board-actions { position: absolute; z-index: 4; bottom: 17px; left: 50%; transform: translateX(-50%); display: flex; align-items: center; height: 43px; background: #102a24ee; border: 1px solid #3c564e; box-shadow: 0 8px 24px #0009; padding: 4px; }
|
.board-actions { position: absolute; z-index: 4; bottom: 17px; left: 50%; transform: translateX(-50%); display: flex; align-items: center; height: 43px; background: #102a24ee; border: 1px solid #3c564e; box-shadow: 0 8px 24px #0009; padding: 4px; }
|
||||||
.board-actions button { height: 33px; border: 0; background: transparent; padding: 0 10px; display: flex; align-items: center; gap: 7px; font: 9px IBM Plex Mono; cursor: pointer; color: #a8b8b2; }
|
.board-actions button { height: 33px; border: 0; background: transparent; padding: 0 10px; display: flex; align-items: center; gap: 7px; font: 9px IBM Plex Mono; cursor: pointer; color: #a8b8b2; }
|
||||||
.board-actions button:hover, .board-actions button.active { background: #27443c; color: #e4a35e; }
|
.board-actions button:hover, .board-actions button.active { background: #27443c; color: #e4a35e; }
|
||||||
|
.board-actions button.thread-tool:not(:disabled) { color: #d35b52; }
|
||||||
|
.board-actions button.thread-tool.active { background: #4a211f; color: #ff8a78; box-shadow: inset 0 0 0 1px #8f3934; }
|
||||||
.board-actions button:disabled { opacity: .35; cursor: default; }
|
.board-actions button:disabled { opacity: .35; cursor: default; }
|
||||||
.board-actions span { width: 1px; height: 24px; background: #385149; margin: 0 5px; }
|
.board-actions span { width: 1px; height: 24px; background: #385149; margin: 0 5px; }
|
||||||
.board-actions b { width: 44px; text-align: center; color: #91a69f; font: 9px IBM Plex Mono; }
|
.board-actions b { width: 44px; text-align: center; color: #91a69f; font: 9px IBM Plex Mono; }
|
||||||
@@ -159,6 +171,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; }
|
.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; }
|
.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; }
|
||||||
.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; }
|
||||||
@@ -212,4 +225,4 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
|||||||
.boot { height: 100vh; background: #071916; display: grid; place-content: center; justify-items: center; color: #819b93; font: 11px IBM Plex Mono; letter-spacing: .15em; }.boot .seal { width: 70px; height: 70px; display: grid; place-items: center; border: 2px solid #b87337; color: #d58a46; margin-bottom: 24px; font-weight: 600; }.boot small { color: #4e6a62; }
|
.boot { height: 100vh; background: #071916; display: grid; place-content: center; justify-items: center; color: #819b93; font: 11px IBM Plex Mono; letter-spacing: .15em; }.boot .seal { width: 70px; height: 70px; display: grid; place-items: center; border: 2px solid #b87337; color: #d58a46; margin-bottom: 24px; font-weight: 600; }.boot small { color: #4e6a62; }
|
||||||
.empty-archive { height: 100vh; display: grid; place-content: center; justify-items: center; text-align: center; background: radial-gradient(circle, #123029 0, #071916 65%); color: #9bb0a9; }.empty-archive .seal { width: 72px; height: 72px; display: grid; place-items: center; border: 2px solid #b87337; color: #d58a46; font: 600 14px IBM Plex Mono; margin-bottom: 25px; }.empty-archive small { font: 9px IBM Plex Mono; letter-spacing: .18em; color: #68837b; }.empty-archive h1 { margin: 12px 0 5px; color: #e0e5e1; font: 27px Special Elite; }.empty-archive p { font-size: 12px; }.empty-archive button { margin-top: 18px; display: flex; align-items: center; gap: 8px; background: #1a493d; border: 1px solid #6f8f85; padding: 11px 16px; font: 10px IBM Plex Mono; cursor: pointer; }.empty-archive .hint { margin-top: 20px; color: #718a83; }.empty-archive code { color: #d59450; }
|
.empty-archive { height: 100vh; display: grid; place-content: center; justify-items: center; text-align: center; background: radial-gradient(circle, #123029 0, #071916 65%); color: #9bb0a9; }.empty-archive .seal { width: 72px; height: 72px; display: grid; place-items: center; border: 2px solid #b87337; color: #d58a46; font: 600 14px IBM Plex Mono; margin-bottom: 25px; }.empty-archive small { font: 9px IBM Plex Mono; letter-spacing: .18em; color: #68837b; }.empty-archive h1 { margin: 12px 0 5px; color: #e0e5e1; font: 27px Special Elite; }.empty-archive p { font-size: 12px; }.empty-archive button { margin-top: 18px; display: flex; align-items: center; gap: 8px; background: #1a493d; border: 1px solid #6f8f85; padding: 11px 16px; font: 10px IBM Plex Mono; cursor: pointer; }.empty-archive .hint { margin-top: 20px; color: #718a83; }.empty-archive code { color: #d59450; }
|
||||||
@media (max-width: 900px) { .menubar { grid-template-columns: 1fr auto; }.menubar nav { display: none; }.terminal-status { font-size: 0; }.documents-panel { width: 275px; }.documents-panel.closed { margin-left: -275px; }.timeline { grid-template-columns: 115px 1fr; padding: 0 12px; }.timeline-key { display: none; }.timeline-track { margin: 0 23px; }.document-window { width: 80vw; }.case-heading { left: 18px; }.case-number { display: none; }.folder-member, .file-editor-grid { grid-template-columns: 1fr; gap: 7px; } }
|
@media (max-width: 900px) { .menubar { grid-template-columns: 1fr auto; }.menubar nav { display: none; }.terminal-status { font-size: 0; }.documents-panel { width: 275px; }.documents-panel.closed { margin-left: -275px; }.timeline { grid-template-columns: 115px 1fr; padding: 0 12px; }.timeline-key { display: none; }.timeline-track { margin: 0 23px; }.document-window { width: 80vw; }.case-heading { left: 18px; }.case-number { display: none; }.folder-member, .file-editor-grid { grid-template-columns: 1fr; gap: 7px; } }
|
||||||
@media (prefers-reduced-motion: reduce) { .evidence-card.arriving, .brief-concepts section.just-resolved { animation: none; }.board, .documents-panel { transition: none; } }
|
@media (prefers-reduced-motion: reduce) { .evidence-card.arriving, .brief-concepts section.just-resolved, .connections g.tightening path { animation: none; }.board, .documents-panel { transition: none; } }
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ export interface Connection {
|
|||||||
id: string
|
id: string
|
||||||
fromEvidenceId: string
|
fromEvidenceId: string
|
||||||
toEvidenceId: string
|
toEvidenceId: string
|
||||||
|
label?: string
|
||||||
|
/** Percentage from slack (0) to taut (100). */
|
||||||
|
tightness?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
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