Refine brief and luggage tag visuals
This commit is contained in:
+38
-8
@@ -3,7 +3,7 @@ import { BookOpen, Building2, CalendarClock, Camera, Check, ChevronRight, Circle
|
||||
import type { BriefConcept, CaseDocument, CaseReport, CaseReportSubmissionInput, CaseState, Connection, DocumentCaptureKind, DocumentSemanticAnalysis, EventExhibit, Evidence, EvidenceMatchRuleDefinition, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, LevelFlag, LevelGoal, NotePresentation, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView, UploadedCaseDocument } from './types'
|
||||
import { AdminPanel } from './admin'
|
||||
import { audio } from './audio'
|
||||
import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, nextVisibleBoardPosition, normalizeCase, panViewport, projectThreadTag, threadCurve, threadTagPlacement, timelinePositionPercent, timelineRange, viewportCenteredOnExhibit, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain'
|
||||
import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, nextVisibleBoardPosition, normalizeCase, panViewport, projectThreadTag, threadCurve, threadTagLeadIn, threadTagPlacement, timelinePositionPercent, timelineRange, viewportCenteredOnExhibit, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain'
|
||||
import { defaultConnectionLabel, documentCapture, documentCaptureRegistry, documentExhibits, documentWidget, evidenceExhibits, exhibitWidget, mugshotIdentification, type ExhibitWidgetContext, type WidgetCommand } from './exhibitRegistry'
|
||||
import type { PlaythroughState } from './narrative'
|
||||
|
||||
@@ -23,6 +23,7 @@ function screenshotFile(file: File, index: number) {
|
||||
return new File([file], `Screenshot ${timestamp}${index ? ` ${index + 1}` : ''}.${extension}`, { type: file.type || 'image/png', lastModified: Date.now() })
|
||||
}
|
||||
function briefAcknowledgementKey(levelId: string) { return `gupi-osint-board:brief-acknowledged:${levelId}` }
|
||||
function reportAcknowledgementKey(levelId: string) { return `gupi-osint-board:report-acknowledged:${levelId}` }
|
||||
function documentSearchText(document: CaseDocument) {
|
||||
return [document.title, document.fileType, document.captureKind,document.publishedAt, document.capturedAt, document.sourceCitation,document.sourceUri,document.fileName, document.mimeType,
|
||||
...document.body, ...document.regions.flatMap(region => [region.label, region.excerpt, region.date]),
|
||||
@@ -74,6 +75,8 @@ export function App() {
|
||||
const [newPartyDraft, setNewPartyDraft] = useState<PartyExhibit | null>(null)
|
||||
const [briefOpen, setBriefOpen] = useState(false)
|
||||
const [reportOpen, setReportOpen] = useState(false)
|
||||
const [briefAcknowledged, setBriefAcknowledged] = useState(false)
|
||||
const [reportAcknowledged, setReportAcknowledged] = useState(false)
|
||||
const [playerName, setPlayerName] = useState('Player')
|
||||
const [editingBrief, setEditingBrief] = useState(false)
|
||||
const [editingTimeline, setEditingTimeline] = useState(false)
|
||||
@@ -100,6 +103,8 @@ export function App() {
|
||||
if (!response.ok) throw new Error('Level unavailable')
|
||||
const data = normalizeCase(await response.json())
|
||||
setCaseState(data)
|
||||
setBriefAcknowledged(Boolean(localStorage.getItem(briefAcknowledgementKey(data.id))))
|
||||
setReportAcknowledged(Boolean(localStorage.getItem(reportAcknowledgementKey(data.id))))
|
||||
const arrivals = data.newlyVisibleDocumentIds || []
|
||||
if (arrivals.length) {
|
||||
setArrivingExhibitIds(arrivals)
|
||||
@@ -181,6 +186,18 @@ export function App() {
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [arrivingExhibitIds])
|
||||
|
||||
useEffect(() => {
|
||||
if (!caseState) return
|
||||
if (briefOpen && !briefAcknowledged) {
|
||||
localStorage.setItem(briefAcknowledgementKey(caseState.id), new Date().toISOString())
|
||||
setBriefAcknowledged(true)
|
||||
}
|
||||
if (reportOpen && !reportAcknowledged) {
|
||||
localStorage.setItem(reportAcknowledgementKey(caseState.id), new Date().toISOString())
|
||||
setReportAcknowledged(true)
|
||||
}
|
||||
}, [briefOpen, reportOpen, briefAcknowledged, reportAcknowledged, caseState])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!caseState || initiallyFocusedLevelRef.current === caseState.id) return
|
||||
const board = boardRef.current
|
||||
@@ -300,7 +317,10 @@ export function App() {
|
||||
}
|
||||
|
||||
const closeBrief = () => {
|
||||
if (caseState) localStorage.setItem(briefAcknowledgementKey(caseState.id), new Date().toISOString())
|
||||
if (caseState) {
|
||||
localStorage.setItem(briefAcknowledgementKey(caseState.id), new Date().toISOString())
|
||||
setBriefAcknowledged(true)
|
||||
}
|
||||
setBriefOpen(false)
|
||||
}
|
||||
|
||||
@@ -574,8 +594,8 @@ export function App() {
|
||||
const classificationDocument = documentClassificationQueue.length ? documents.find(document => document.id === documentClassificationQueue[0]) || null : null
|
||||
const unresolvedConceptCount = caseState.brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length
|
||||
const pendingGoalCount = caseState.goals.filter(goal => goal.status === 'pending').length
|
||||
const briefAttentionCount = unresolvedConceptCount + pendingGoalCount
|
||||
const reportAttentionCount = caseState.report?.requiredForCompletion && caseState.report.status !== 'accepted' ? 1 : 0
|
||||
const briefAttentionCount = !briefAcknowledged ? unresolvedConceptCount + pendingGoalCount : 0
|
||||
const reportAttentionCount = !reportAcknowledged && caseState.report?.requiredForCompletion && caseState.report.status !== 'accepted' ? 1 : 0
|
||||
const canAuthor = isAdmin && requestedEditMode && Boolean(caseState.editingAllowed)
|
||||
const temporalItems: TemporalItem[] = caseState.exhibits.flatMap(exhibit => {
|
||||
const widget=exhibitWidget(exhibit.type)
|
||||
@@ -990,6 +1010,9 @@ function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedEx
|
||||
{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>
|
||||
{state.connections.map(connection => { const p1 = pointForId(connection.fromExhibitId), p2 = pointForId(connection.toExhibitId); if (!p1 || !p2) return null; const tagPlacement = threadTagPlacement(p1, p2, connection.tightness, connection.tagPosition, 0); const compact = connection.tagStyle === 'compact'; const dragging = draggingThreadTagId === connection.id; return <button key={`tag:${connection.id}`} aria-label={connection.label ? `Relation tag: ${connection.label}` : 'Edit untagged red thread'} className={`thread-tag ${connection.label ? `labelled ${compact ? 'compact' : 'luggage luggage-tag'}` : 'untagged'} ${dragging ? 'dragging' : ''}`} style={{ left: tagPlacement.x, top: tagPlacement.y }} title={connection.label ? dragging ? `Position ${Math.round(tagPlacement.positionPercent)}%` : 'Drag along thread · click to edit' : 'Edit thread tag and tightness'} onPointerDown={event => connection.label ? threadTagPointerDown(event, connection.id) : event.stopPropagation()} onClick={event => { event.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } onEditConnection(connection) }}><i/>{dragging && <output className="thread-tag-position">{Math.round(tagPlacement.positionPercent)}%</output>}{connection.label && (compact ? <span className="thread-tag-compact-label">{connection.label}</span> : <span className="thread-tag-content"><small>RELATION TAG</small><b>{connection.label}</b></span>)}</button> })}
|
||||
<svg className="thread-lead-ins" width={BOARD_W} height={BOARD_H} aria-hidden="true">
|
||||
{state.connections.map(connection => { if (!connection.label || connection.tagStyle === 'compact') return null; const p1 = pointForId(connection.fromExhibitId), p2 = pointForId(connection.toExhibitId); if (!p1 || !p2) return null; return <path key={`lead-in:${connection.id}`} d={threadTagLeadIn(p1, p2, connection.tightness, connection.tagPosition).path}/> })}
|
||||
</svg>
|
||||
<svg className="event-support-lines" width={BOARD_W} height={BOARD_H}>
|
||||
{state.relations.filter(relation => relation.type === 'supports').map(relation => { const origin = pointForId(relation.fromExhibitId), target = pointForId(relation.toExhibitId); return origin && target ? <line key={relation.id} x1={origin.x} y1={origin.y} x2={target.x} y2={target.y}/> : null })}
|
||||
</svg>
|
||||
@@ -1243,13 +1266,20 @@ function BriefPanel({ brief, goals, parties, recentlyCreatedExhibitId, canEdit,
|
||||
const unresolved = brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length
|
||||
const pending = goals.filter(goal => goal.status === 'pending').length
|
||||
const heading = goals.length ? brief.concepts.length ? 'CASE OBJECTIVES' : 'ASSIGNMENT' : 'CONCEPT CLASSIFICATION'
|
||||
const authoredBody = brief.body.trim()
|
||||
const [firstLine = '', ...remainingLines] = authoredBody.split(/\r?\n/)
|
||||
const startsWithDisplayHeading = remainingLines.some(line => line.trim()) && firstLine.length < 70 && firstLine === firstLine.toLocaleUpperCase()
|
||||
const assignment = (startsWithDisplayHeading ? remainingLines.join('\n').trim() : authoredBody)
|
||||
|| goals.map(goal => [goal.title, goal.instructions].filter(Boolean).join('\n')).join('\n\n')
|
||||
|| 'No brief has been authored yet.'
|
||||
const checklist = brief.checklist || []
|
||||
return <aside className={`brief-panel ${minimized ? 'minimized' : ''}`}><header onDoubleClick={() => setMinimized(value => !value)}><div><small>LEVEL BRIEF · {pending ? `${pending} OBJECTIVE${pending === 1 ? '' : 'S'} OPEN` : unresolved ? `${unresolved} UNRESOLVED` : 'READY'}</small><b>{heading}</b></div><span/><button type="button" aria-label={minimized ? 'Restore brief' : 'Minimize brief'} title={minimized ? 'Restore' : 'Minimize'} onDoubleClick={event => event.stopPropagation()} onClick={() => setMinimized(value => !value)}>{minimized ? <Plus size={14}/> : <Minus size={14}/>}</button><button type="button" aria-label="Close brief" title="Close" onDoubleClick={event => event.stopPropagation()} onClick={onClose}><X size={14}/></button></header>
|
||||
<p>{brief.body || 'No brief has been authored yet.'}</p>
|
||||
{goals.length > 0 && <div className="brief-goals">{goals.map((goal, index) => <section className={goal.status} key={goal.key}><i>{goal.status === 'complete' ? '✓' : index + 1}</i><div><b>{goal.title}</b><span>{goal.instructions}</span></div><em>{goal.status === 'complete' ? 'VERIFIED' : 'OPEN'}</em></section>)}</div>}
|
||||
<p>{assignment}</p>
|
||||
{checklist.length > 0 && <section className="brief-checklist"><h3><ClipboardCheck size={16}/> CHECKLIST</h3><ol>{checklist.map(item => <li key={item.id}>{item.text}</li>)}</ol></section>}
|
||||
{brief.concepts.length > 0 && <><div className="brief-concepts">{brief.concepts.map(concept => { const resolved = concept.resolvedPartyExhibitId ? partyById.get(concept.resolvedPartyExhibitId) : undefined; return <section className={`${resolved ? 'resolved' : ''} ${resolved?.id === recentlyCreatedExhibitId ? 'just-resolved' : ''}`} key={concept.id}><div><b>{concept.label}</b><span>{concept.context}</span></div>{resolved ? <div className="resolved-actions"><span>{resolved.partyKind === 'person' ? <UserRound size={14}/> : <Building2 size={14}/>} {resolved.partyKind?.toUpperCase()}</span><button onClick={() => onLocate(resolved.id)}>LOCATE</button><button onClick={() => onEditParty(resolved.id)}>EDIT DOSSIER</button></div> : <div className="classify-actions"><button onClick={() => onClassify(concept.id, 'person')}><UserRound size={14}/> PERSON</button><button onClick={() => onClassify(concept.id, 'organization')}><Building2 size={14}/> ORGANIZATION</button></div>}</section> })}</div>
|
||||
<button className="new-party-from-brief" onClick={onNewParty}><Plus size={13}/> CREATE PARTY NOT LISTED ABOVE</button></>}
|
||||
{canEdit && <button className="edit-brief" onClick={onEdit}><Pencil size={13}/> EDIT BRIEF & CONCEPTS</button>}
|
||||
<button className="dismiss-brief" onClick={onClose}>{pending || unresolved === brief.concepts.length ? 'BEGIN INVESTIGATION' : 'RETURN TO BOARD'}</button>
|
||||
<button className="dismiss-brief" onClick={onClose}>{pending || unresolved ? 'BEGIN INVESTIGATION' : 'RETURN TO BOARD'}</button>
|
||||
</aside>
|
||||
}
|
||||
|
||||
@@ -1270,7 +1300,7 @@ function BriefEditor({ brief, onClose, onSave }: { brief: LevelBrief; onClose: (
|
||||
const [body, setBody] = useState(brief.body)
|
||||
const [concepts, setConcepts] = useState<BriefConcept[]>(brief.concepts)
|
||||
const addConcept = () => setConcepts(current => [...current, { id: uid('concept'), label: '', context: '', expectedPartyKind: 'person' }])
|
||||
return <div className="modal-shade"><form className="window folder-editor brief-editor" onSubmit={submit => { submit.preventDefault(); onSave({ body: body.trim(), concepts: concepts.filter(item => item.label.trim()).map(item => ({ ...item, label: item.label.trim(), context: item.context.trim() })) }) }}>
|
||||
return <div className="modal-shade"><form className="window folder-editor brief-editor" onSubmit={submit => { submit.preventDefault(); onSave({ ...brief, body: body.trim(), concepts: concepts.filter(item => item.label.trim()).map(item => ({ ...item, label: item.label.trim(), context: item.context.trim() })) }) }}>
|
||||
<header><BookOpen size={16}/><b>Edit level brief</b><span/><button type="button" aria-label="Close brief editor" onClick={onClose}><X size={14}/></button></header>
|
||||
<div className="folder-editor-body"><small>AUTHORING · PLAYER CONCEPTS</small>
|
||||
<label className="field"><span>BRIEF</span><textarea aria-label="Level brief" rows={5} value={body} onChange={event => setBody(event.target.value)}/></label>
|
||||
|
||||
Reference in New Issue
Block a user