feat: add configurable timeline range

This commit is contained in:
2026-08-14 15:40:54 +02:00
parent b284275e98
commit 45c7c6a292
15 changed files with 121 additions and 14 deletions
+34 -7
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { BookOpen, Building2, CalendarClock, ChevronRight, CircleHelp, FileText, FolderOpen, Hand, Image as ImageIcon, Link2, Minus, MousePointer2, Network, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, UserRound, X, ZoomIn, ZoomOut } from 'lucide-react'
import type { BriefConcept, CaseDocument, CaseState, Evidence, LevelBrief, OrganizationKind, PartyKind, SourceFileType, WidgetRelation } from './types'
import type { BriefConcept, CaseDocument, CaseState, Evidence, LevelBrief, OrganizationKind, PartyKind, SourceFileType, TimelineRange, WidgetRelation } from './types'
import { clampBoardZoom, containedIds, dateValue, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, relationPosition, timelinePositionPercent, timelineRange, zoomFromWheel } from './boardDomain'
import { documentWidget, exhibitWidget } from './exhibitRegistry'
@@ -36,6 +36,7 @@ export function App() {
const [editingPartyId, setEditingPartyId] = useState<string | null>(null)
const [briefOpen, setBriefOpen] = useState(false)
const [editingBrief, setEditingBrief] = useState(false)
const [editingTimeline, setEditingTimeline] = useState(false)
const saveTimer = useRef<number | undefined>(undefined)
const boardRef = useRef<HTMLDivElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
@@ -234,7 +235,7 @@ export function App() {
<header className="menubar">
<div className="brand"><span className="brand-mark">GU</span><span>OSINT BOARD <em>/ {requestedEditMode && caseState.editingAllowed ? 'LEVEL EDITOR' : 'CASE TERMINAL'}</em></span></div>
<nav>
<button onClick={() => setDocsOpen(v => !v)}>FILE</button><button onClick={() => setBriefOpen(value => !value)}>BRIEF</button><button onClick={addNote}>EVIDENCE</button><button onClick={() => update(s => ({ ...s, viewport: { x: 0, y: 28, zoom: .7 } }))}>VIEW</button><button onClick={() => { const firstWidget = temporalItems.find(item => item.evidenceId); if (firstWidget?.evidenceId) focusEvidence(firstWidget.evidenceId) }}>TIMELINE</button>{requestedEditMode && caseState.editingAllowed && <><button onClick={saveAsTemplate}>SAVE TEMPLATE</button><button onClick={instantiateTemplate}>NEW FROM TEMPLATE</button></>}<button onClick={() => setHelpOpen(true)}>HELP</button>
<button onClick={() => setDocsOpen(v => !v)}>FILE</button><button onClick={() => setBriefOpen(value => !value)}>BRIEF</button><button onClick={addNote}>EVIDENCE</button><button onClick={() => update(s => ({ ...s, viewport: { x: 0, y: 28, zoom: .7 } }))}>VIEW</button><button onClick={() => setEditingTimeline(true)}>TIMELINE</button>{requestedEditMode && caseState.editingAllowed && <><button onClick={saveAsTemplate}>SAVE TEMPLATE</button><button onClick={instantiateTemplate}>NEW FROM TEMPLATE</button></>}<button onClick={() => setHelpOpen(true)}>HELP</button>
</nav>
<div className="terminal-status"><i /> {status}<span>{clock}</span></div>
</header>
@@ -285,7 +286,7 @@ export function App() {
</section>
<TemporalLinks items={temporalItems} layoutKey={`${caseState.viewport.x}:${caseState.viewport.y}:${caseState.viewport.zoom}:${docsOpen}:${caseState.evidence.map(e => `${e.id}:${e.x}:${e.y}:${String(e.config?.open)}`).join('|')}:${caseState.relations.map(r => `${r.id}:${String(r.config?.x)}:${String(r.config?.y)}`).join('|')}`}/>
<Timeline items={temporalItems} selected={selected} onSelect={item => { if (item.documentId) setOpenDoc(caseState.documents.find(doc => doc.id === item.documentId) || null); else if (item.evidenceId) focusEvidence(item.evidenceId) }}/>
<Timeline items={temporalItems} range={caseState.timelineRange} selected={selected} onEdit={() => setEditingTimeline(true)} onSelect={item => { if (item.documentId) setOpenDoc(caseState.documents.find(doc => doc.id === item.documentId) || null); else if (item.evidenceId) focusEvidence(item.evidenceId) }}/>
{openDoc && <DocumentWindow doc={openDoc} onClose={() => setOpenDoc(null)} onExtract={id => extract(openDoc, id)} extracted={caseState.evidence.filter(e => e.sourceDocumentId === openDoc.id).map(e => e.sourceRegionId)} />}
{editingFolderId && <FolderEditor key={editingFolderId} folder={caseState.evidence.find(widget => widget.id === editingFolderId)!} memberIds={containedIds(caseState, editingFolderId)} documents={caseState.documents} canManageContents={requestedEditMode && Boolean(caseState.editingAllowed)} onClose={() => setEditingFolderId(null)} onSave={(folder, members) => { update(state => ({ ...state, evidence: state.evidence.map(widget => widget.id === folder.id ? { ...folder, containedDocumentIds: members } : widget), relations: [...state.relations.filter(relation => relation.type !== 'contains' || relation.fromWidgetId !== folder.id), ...members.map((documentId, index) => { const existing = state.relations.find(relation => relation.type === 'contains' && relation.fromWidgetId === folder.id && relation.toWidgetId === documentId); const position = relationPosition(state, existing || { id: '', fromWidgetId: folder.id, toWidgetId: documentId, type: 'contains', sortOrder: index }); return { id: existing?.id || `contains:${folder.id}:${documentId}`, fromWidgetId: folder.id, toWidgetId: documentId, type: 'contains', sortOrder: index, config: existing?.config || position } })] })); setEditingFolderId(null); setStatus('FOLDER UPDATED') }}/>}
{editingFileId && <FileEditor key={editingFileId} document={caseState.documents.find(document => document.id === editingFileId)!} onClose={() => setEditingFileId(null)} onSave={document => { update(state => ({ ...state, documents: state.documents.map(candidate => candidate.id === document.id ? document : candidate) })); setEditingFileId(null); setStatus('FILE METADATA UPDATED') }}/>}
@@ -318,6 +319,16 @@ export function App() {
setStatus('LEVEL BRIEF UPDATED')
}}
/>}
{editingTimeline && <TimelineRangeEditor
range={caseState.timelineRange}
dates={temporalItems.map(item => item.date)}
onClose={() => setEditingTimeline(false)}
onSave={timelineRange => {
update(state => ({ ...state, timelineRange }))
setEditingTimeline(false)
setStatus(timelineRange ? 'TIMELINE RANGE UPDATED' : 'TIMELINE RANGE AUTOMATIC')
}}
/>}
{helpOpen && <Help onClose={() => setHelpOpen(false)}/>}
</main>
}
@@ -464,11 +475,13 @@ function TemporalLinks({ items, layoutKey }: { items: TemporalItem[]; layoutKey:
return <svg className="temporal-links" aria-hidden="true">{lines.map(line => <line key={line.id} x1={line.x1} y1={line.y1} x2={line.x2} y2={line.y2}/>)}</svg>
}
function Timeline({ items, selected, onSelect }: { items: TemporalItem[]; selected: string | null; onSelect: (item: TemporalItem) => void }) {
const { startYear, endYear, start, end } = timelineRange(items.map(item => item.date))
const years = Array.from({ length: endYear - startYear + 1 }, (_, index) => startYear + index)
function Timeline({ items, range, selected, onSelect, onEdit }: { items: TemporalItem[]; range?: TimelineRange | null; selected: string | null; onSelect: (item: TemporalItem) => void; onEdit: () => void }) {
const { startYear, endYear, start, end } = timelineRange(items.map(item => item.date), undefined, range || undefined)
const position = (date: string) => timelinePositionPercent(date, { start, end })
return <footer className="timeline"><div className="timeline-label"><small>TEMPORAL INDEX</small><b>TIMELINE</b><span>{items.length} DATED ITEMS</span></div><div className="timeline-track"><div className="axis"/>{years.map(year => <span className="year" key={year} style={{ left: `${position(`${year}-01-01`)}%` }}>{year}</span>)}{items.map((item, i) => <button key={item.id} data-marker-id={item.id} className={`marker ${item.kind} ${item.evidenceId === selected ? 'selected' : ''}`} style={{ left: `${position(item.date)}%`, top: i % 2 ? 12 : 31 }} onClick={() => onSelect(item)} title={`${item.date.slice(0, 10)}${item.label}`}><i/></button>)}</div><div className="timeline-key"><span><i/> SOURCE</span><span className="amber"><i/> SELECTED</span></div></footer>
const ticks = range
? Array.from({ length: 5 }, (_, index) => { const value = start + (end - start) * index / 4; return { value, label: new Date(value).toISOString().slice(5, 10) } })
: Array.from({ length: endYear - startYear + 1 }, (_, index) => { const year = startYear + index; return { value: Date.parse(`${year}-01-01T00:00:00.000Z`), label: String(year) } })
return <footer className="timeline"><div className="timeline-label"><small>TEMPORAL INDEX</small><b>TIMELINE</b><button onClick={onEdit}>{range ? `${range.start}${range.end}` : `${items.length} DATED ITEMS · AUTO`}</button></div><div className="timeline-track"><div className="axis"/>{ticks.map((tick, index) => <span className="year" key={`${tick.value}:${index}`} style={{ left: `${timelinePositionPercent(new Date(tick.value).toISOString(), { start, end })}%` }}>{tick.label}</span>)}{items.map((item, i) => <button key={item.id} data-marker-id={item.id} className={`marker ${item.kind} ${item.evidenceId === selected ? 'selected' : ''}`} style={{ left: `${position(item.date)}%`, top: i % 2 ? 12 : 31 }} onClick={() => onSelect(item)} title={`${item.date.slice(0, 10)}${item.label}`}><i/></button>)}</div><div className="timeline-key"><span><i/> SOURCE</span><span className="amber"><i/> SELECTED</span></div></footer>
}
function localDateTime(value?: string) {
@@ -480,6 +493,20 @@ function localDateTime(value?: string) {
return local.toISOString().slice(0, 16)
}
function TimelineRangeEditor({ range, dates, onClose, onSave }: { range?: TimelineRange | null; dates: string[]; onClose: () => void; onSave: (range: TimelineRange | null) => void }) {
const dated = dates.map(date => date.slice(0, 10)).filter(Boolean).sort()
const [start, setStart] = useState(range?.start || dated[0] || '')
const [end, setEnd] = useState(range?.end || dated[dated.length - 1] || '')
const valid = Boolean(start && end && end > start)
return <div className="modal-shade"><form className="window timeline-editor" onSubmit={event => { event.preventDefault(); if (valid) onSave({ start, end }) }}>
<header><CalendarClock size={16}/><b>Adjust timeline range</b><span/><button type="button" aria-label="Close timeline editor" onClick={onClose}><X size={14}/></button></header>
<div><small>TEMPORAL VIEWPORT · BOARD SETTING</small><p>Choose the interval shown across the full timeline. Dated evidence outside it is pinned to the nearest edge.</p>
<div className="timeline-range-fields"><label className="field"><span>START DATE</span><input aria-label="Timeline start date" type="date" required value={start} onChange={event => setStart(event.target.value)}/></label><label className="field"><span>END DATE</span><input aria-label="Timeline end date" type="date" required value={end} min={start} onChange={event => setEnd(event.target.value)}/></label></div>
<div className="folder-editor-actions"><button type="button" onClick={() => onSave(null)}>USE AUTOMATIC RANGE</button><button className="primary" type="submit" disabled={!valid}>APPLY RANGE</button></div>
</div>
</form></div>
}
function BriefPanel({ brief, parties, canEdit, onClose, onEdit, onClassify, onLocate }: { brief: LevelBrief; parties: Evidence[]; canEdit: boolean; onClose: () => void; onEdit: () => void; onClassify: (id: string, kind: PartyKind) => void; onLocate: (id: string) => void }) {
const partyById = new Map(parties.map(party => [party.id, party]))
return <aside className="brief-panel"><header><div><small>LEVEL BRIEF</small><b>CONCEPT CLASSIFICATION</b></div><button aria-label="Close brief" onClick={onClose}><X size={14}/></button></header>