feat: add configurable timeline range
This commit is contained in:
+34
-7
@@ -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>
|
||||
|
||||
@@ -90,6 +90,14 @@ describe('timeline projection', () => {
|
||||
expect(timelinePositionPercent('2022-07-02', range)).toBeGreaterThan(49)
|
||||
expect(timelinePositionPercent('2022-07-02', range)).toBeLessThan(51)
|
||||
})
|
||||
|
||||
it('uses a valid configured date range without automatic year padding', () => {
|
||||
const range = timelineRange(['1987-01-08', '1987-10-24'], 2026, { start: '1987-10-01', end: '1987-10-31' })
|
||||
expect(new Date(range.start).toISOString()).toBe('1987-10-01T00:00:00.000Z')
|
||||
expect(new Date(range.end).toISOString()).toBe('1987-10-31T23:59:59.999Z')
|
||||
expect(timelinePositionPercent('1987-10-16', range)).toBeGreaterThan(48)
|
||||
expect(timelinePositionPercent('1987-10-16', range)).toBeLessThan(52)
|
||||
})
|
||||
})
|
||||
|
||||
describe('folder domain behavior', () => {
|
||||
|
||||
+9
-2
@@ -1,4 +1,4 @@
|
||||
import type { CaseState, Evidence, Viewport, WidgetRelation } from './types'
|
||||
import type { CaseState, Evidence, TimelineRange, Viewport, WidgetRelation } from './types'
|
||||
|
||||
export const MIN_BOARD_ZOOM = 0.45
|
||||
export const MAX_BOARD_ZOOM = 1.5
|
||||
@@ -48,7 +48,14 @@ export function dateValue(date: string) {
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
}
|
||||
|
||||
export function timelineRange(dates: string[], fallbackYear = new Date().getFullYear()) {
|
||||
export function timelineRange(dates: string[], fallbackYear = new Date().getFullYear(), configured?: TimelineRange) {
|
||||
if (configured) {
|
||||
const start = Date.parse(`${configured.start}T00:00:00.000Z`)
|
||||
const end = Date.parse(`${configured.end}T23:59:59.999Z`)
|
||||
if (Number.isFinite(start) && Number.isFinite(end) && end > start) {
|
||||
return { startYear: Number(configured.start.slice(0, 4)), endYear: Number(configured.end.slice(0, 4)), start, end }
|
||||
}
|
||||
}
|
||||
const years = dates
|
||||
.map(date => Number(date.slice(0, 4)))
|
||||
.filter(year => Number.isFinite(year) && year >= 1 && year <= 9999)
|
||||
|
||||
+2
-1
@@ -143,7 +143,7 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.timeline { background: #0d231e; border-top: 1px solid #3c544d; display: grid; grid-template-columns: 180px 1fr 165px; align-items: center; padding: 0 25px; z-index: 8; }
|
||||
.temporal-links { position: fixed; z-index: 7; inset: 0; width: 100vw; height: 100vh; pointer-events: none; overflow: visible; }.temporal-links line { stroke: #8b9792; stroke-width: 1; opacity: .42; vector-effect: non-scaling-stroke; }
|
||||
.timeline-label { border-right: 1px solid #314a43; height: 67px; display: flex; flex-direction: column; justify-content: center; }
|
||||
.timeline-label b { font: 600 14px IBM Plex Mono; margin: 4px 0; }.timeline-label span { font: 8px IBM Plex Mono; color: #a07142; }
|
||||
.timeline-label b { font: 600 14px IBM Plex Mono; margin: 4px 0; }.timeline-label button { width: max-content; max-width: 170px; padding: 0; overflow: hidden; color: #a07142; background: transparent; border: 0; text-overflow: ellipsis; white-space: nowrap; text-align: left; cursor: pointer; font: 8px IBM Plex Mono; }
|
||||
.timeline-track { height: 75px; margin: 0 43px; position: relative; }
|
||||
.axis { position: absolute; left: 0; right: 0; top: 45px; height: 1px; background: #61736d; }
|
||||
.year { position: absolute; top: 53px; transform: translateX(-50%); font: 9px IBM Plex Mono; color: #6f8981; }
|
||||
@@ -154,6 +154,7 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.marker.selected i { border-color: #eea458; 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; }
|
||||
.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; }
|
||||
.window > header { height: 31px; display: flex; align-items: center; gap: 8px; padding: 0 5px 0 9px; color: #dfe9e4; background: #183f36; font: 500 11px IBM Plex Mono; cursor: move; touch-action: none; }
|
||||
.window > header span { flex: 1; }.window > header button { width: 22px; height: 21px; display: grid; place-items: center; padding: 0; background: #b7bcb4; border: 1px outset white; color: #17221f; cursor: pointer; }
|
||||
.document-window { width: min(610px, 60vw); }.document-window.minimized { width: min(380px, 60vw); }.document-window > nav { height: 28px; padding: 7px 10px; background: #aeb4ac; border-bottom: 1px solid #727c76; font: 9px IBM Plex Mono; }
|
||||
|
||||
@@ -63,6 +63,7 @@ export interface Connection {
|
||||
}
|
||||
|
||||
export interface Viewport { x: number; y: number; zoom: number }
|
||||
export interface TimelineRange { start: string; end: string }
|
||||
|
||||
export interface BriefConcept {
|
||||
id: string
|
||||
@@ -83,6 +84,7 @@ export interface CaseState {
|
||||
relations: WidgetRelation[]
|
||||
connections: Connection[]
|
||||
viewport: Viewport
|
||||
timelineRange?: TimelineRange | null
|
||||
brief: LevelBrief
|
||||
updatedAt?: string
|
||||
levelStatus?: string
|
||||
|
||||
Reference in New Issue
Block a user