5 Commits
Author SHA1 Message Date
gitprov 3c099a5daa Merge branch 'main' of ssh://ramanujan.glitch.university:2222/glitch-university/gupi-osint-board 2026-08-23 01:16:00 +02:00
gitprov 625f28b00b In the middle of refactoring the mystery seeding 2026-08-23 01:15:49 +02:00
gitprov 9e426e91ab Keep new evidence visible on the board 2026-08-23 01:15:05 +02:00
gitprovandClaude Opus 4.8 f6fbeb39cf Make mystery importer seed notes and tolerate sparse manifests
Add a notes[] array to the manifest (note exhibits with presentation),
so the Scene 6 phone-note board is reproducible from source instead of
hand-authored. Also default documents/folders/brief/subtitle so
narrative-only mysteries (e.g. barricelli-files) import cleanly.

Captures mysteries/barricelli-phone-note/mystery.json from the authored
template (note: "PHONE FOR GLITCH HUNTER / Call: 5550100").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 00:43:47 +02:00
gitprovandClaude Opus 4.8 ce30321532 Mark auth cookie Secure in production
secure keys off NODE_ENV so the auth_token cookie is HTTPS-only in
prod while still working over plain HTTP on localhost in dev.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 00:21:42 +02:00
7 changed files with 238 additions and 74 deletions
@@ -0,0 +1,20 @@
{
"slug": "barricelli-phone-note",
"name": "Phone note",
"title": "Phone note",
"subtitle": "",
"brief": { "body": "", "concepts": [] },
"documents": [],
"folders": [],
"notes": [
{
"title": "Note",
"content": "PHONE FOR GLITCH HUNTER\n\nCall: 5550100",
"presentation": "luggage",
"x": 420,
"y": 260,
"width": 230,
"height": 180
}
]
}
+98 -59
View File
@@ -1,8 +1,8 @@
import { randomUUID } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { readFile, stat } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import type { CaseDocument, CaseState, ClaimExhibit, DocumentCaptureKind, PartyKind, SourceFileType } from '../src/types.js'
import type { CaseDocument, CaseState, ClaimExhibit, DocumentCaptureKind, NoteExhibit, NotePresentation, PartyKind, SourceFileType } from '../src/types.js'
type MysteryDocument = {
key: string
@@ -37,22 +37,28 @@ type MysterySemanticRule = {
goalKey:string; name:string; targetSubject:string; relatedSubject?:string; assertion:string; successFlagKey:string
relatedFlagKey?:string; minimumConfidence?:number; evaluatorVersion?:string; enabled?:boolean
}
type MysteryManifest = {
// A single playable board (level template). Assets are filenames resolved against
// the mystery folder (e.g. "assets/photo.png"), matching the on-disk layout.
type MysteryLevel = {
slug: string
name: string
title: string
subtitle: string
subtitle?: string
timelineRange?: { start: string; end: string }
brief: { body: string; concepts: { label: string; context: string; expectedPartyKind: PartyKind }[] }
documents: MysteryDocument[]
folders: { key: string; title: string; content: string; x: number; y: number; width: number; members: string[] }[]
brief?: { body: string; concepts: { label: string; context: string; expectedPartyKind: PartyKind }[] }
documents?: MysteryDocument[]
folders?: { key: string; title: string; content: string; x: number; y: number; width: number; members: string[] }[]
claims?: { key:string;statement:string;x:number;y:number;width?:number;height?:number }[]
notes?: { title?:string;content:string;presentation?:NotePresentation;x:number;y:number;width?:number;height?:number }[]
report?: { title?:string;requiredForCompletion?:boolean }
goals?: MysteryGoal[]
evidenceMatchRules?: MysteryEvidenceMatchRule[]
evidenceSemanticRules?: MysterySemanticRule[]
narrative?: MysteryNarrative
}
// Legacy single-manifest: one level plus an optional narrative in the same file.
type MysteryManifest = MysteryLevel & { narrative?: MysteryNarrative }
// New self-contained format: a mystery with every level it uses embedded.
type MysteryFile = { slug: string; title: string; levels: MysteryLevel[]; narrative?: MysteryNarrative }
function requireOk(response: Response, action: string) {
if (response.ok) return response
@@ -75,24 +81,30 @@ async function uploadAsset(baseUrl: string, levelId: string, manifestDir: string
return await response.json() as CaseDocument
}
export async function importMysteryTemplate(manifestPath: string, baseUrl = 'http://localhost:8787', adminJwt = process.env.OSINT_ADMIN_JWT) {
const absoluteManifest = path.resolve(manifestPath)
const manifest = JSON.parse(await readFile(absoluteManifest, 'utf8')) as MysteryManifest
const manifestDir = path.dirname(absoluteManifest)
const authoringId = `${manifest.slug}-authoring-${Date.now()}`
type ImportHeaders = Record<string, string>
function authHeaders(adminJwt?: string): { authorization?: string; headers: ImportHeaders } {
const authorization = adminJwt ? `Bearer ${adminJwt}` : undefined
const headers = { 'content-type': 'application/json', ...(authorization ? { authorization } : {}) }
return { authorization, headers: { 'content-type': 'application/json', ...(authorization ? { authorization } : {}) } }
}
type LevelResult = { template: { slug: string; currentVersion: number }; authoringLevelId: string; playableLevel: CaseState }
// Import one board: create a mutable authoring level, upload assets, save exhibits,
// goals and evidence rules, freeze an immutable template, and instantiate a playable copy.
async function importLevel(baseUrl: string, folderDir: string, level: MysteryLevel, headers: ImportHeaders, authorization?: string): Promise<LevelResult> {
const authoringId = `${level.slug}-authoring-${Date.now()}`
const createdResponse = await requireOk(await fetch(`${baseUrl}/api/levels`, {
method: 'POST', headers,
body: JSON.stringify({ id: authoringId, title: manifest.title, subtitle: manifest.subtitle }),
body: JSON.stringify({ id: authoringId, title: level.title, subtitle: level.subtitle || '' }),
}), 'Create authoring level')
const state = await createdResponse.json() as CaseState
const documentPositions = new Map<string, { x: number; y: number }>()
manifest.folders.forEach((folder, folderIndex) => folder.members.forEach((key, memberIndex) => documentPositions.set(key, { x: folder.x + 70 + memberIndex * 205, y: folder.y + 230 + folderIndex * 35 })))
const levelFolders = level.folders || []
levelFolders.forEach((folder, folderIndex) => folder.members.forEach((key, memberIndex) => documentPositions.set(key, { x: folder.x + 70 + memberIndex * 205, y: folder.y + 230 + folderIndex * 35 })))
const documents = new Map<string, CaseDocument>()
for (const source of manifest.documents) {
const uploaded = await uploadAsset(baseUrl, state.id, manifestDir, source, authorization)
for (const source of level.documents || []) {
const uploaded = await uploadAsset(baseUrl, state.id, folderDir, source, authorization)
documents.set(source.key, {
id: uploaded?.id || randomUUID(), type: 'document', title: source.title, publishedAt: source.publishedAt,
x: documentPositions.get(source.key)?.x || 100, y: documentPositions.get(source.key)?.y || 100,
@@ -103,17 +115,19 @@ export async function importMysteryTemplate(manifestPath: string, baseUrl = 'htt
})
}
const folderIds = new Map(manifest.folders.map(folder => [folder.key, randomUUID()]))
state.brief = { body: manifest.brief.body, concepts: manifest.brief.concepts.map(concept => ({ id: randomUUID(), ...concept })) }
state.views = state.views.map(view => view.type === 'timeline' ? { ...view, rangeMode: manifest.timelineRange ? 'fixed' : 'auto', range: manifest.timelineRange } : view)
const folders = manifest.folders.map(folder => ({
const folderIds = new Map(levelFolders.map(folder => [folder.key, randomUUID()]))
if (level.brief) state.brief = { body: level.brief.body, concepts: level.brief.concepts.map(concept => ({ id: randomUUID(), ...concept })) }
state.views = state.views.map(view => view.type === 'timeline' ? { ...view, rangeMode: level.timelineRange ? 'fixed' : 'auto', range: level.timelineRange } : view)
const folders = levelFolders.map(folder => ({
id: folderIds.get(folder.key)!, type: 'folder', title: folder.title, content: folder.content,
x: folder.x, y: folder.y, width: folder.width, height: 166, rotation: 0, zIndex: 1, hidden: false, isOpen: false,
} as const))
const claims:ClaimExhibit[]=(manifest.claims || []).map(claim => ({ id:randomUUID(),type:'claim',title:claim.statement,statement:claim.statement,
const claims:ClaimExhibit[]=(level.claims || []).map(claim => ({ id:randomUUID(),type:'claim',title:claim.statement,statement:claim.statement,
x:claim.x,y:claim.y,width:claim.width || 310,height:claim.height || 180,rotation:0,zIndex:2,hidden:false }))
state.exhibits = [...documents.values(), ...folders,...claims]
state.relations = manifest.folders.flatMap(folder => folder.members.map((key, memberIndex) => {
const notes:NoteExhibit[]=(level.notes || []).map(note => ({ id:randomUUID(),type:'note',title:note.title || 'NOTE',content:note.content,
presentation:note.presentation || 'luggage',x:note.x,y:note.y,width:note.width || 230,height:note.height || 180,rotation:0,zIndex:2,hidden:false }))
state.exhibits = [...documents.values(), ...folders,...claims,...notes]
state.relations = levelFolders.flatMap(folder => folder.members.map((key, memberIndex) => {
const document = documents.get(key)
if (!document) throw new Error(`Folder ${folder.key} refers to unknown document ${key}`)
return {
@@ -122,26 +136,26 @@ export async function importMysteryTemplate(manifestPath: string, baseUrl = 'htt
}
}))
state.connections = []
state.report=manifest.report ? { title:manifest.report.title || 'Case Report',investigatorName:'',requiredForCompletion:manifest.report.requiredForCompletion !== false,
state.report=level.report ? { title:level.report.title || 'Case Report',investigatorName:'',requiredForCompletion:level.report.requiredForCompletion !== false,
status:'draft',issues:[],claims:[] } : undefined
state.viewport = { x: 0, y: 28, zoom: 0.7 }
await requireOk(await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
method: 'PUT', headers, body: JSON.stringify(state),
}), 'Save authored mystery')
}), 'Save authored level')
const goalIds = new Map<string,string>()
for (const goal of manifest.goals || []) {
for (const goal of level.goals || []) {
const response = await requireOk(await fetch(`${baseUrl}/api/levels/${state.id}/goals`, {
method:'POST',headers,body:JSON.stringify(goal),
}), `Create goal ${goal.key}`)
const created = await response.json() as { id:string;key:string }
goalIds.set(created.key,created.id)
}
for (const rule of manifest.evidenceMatchRules || []) await requireOk(await fetch(
for (const rule of level.evidenceMatchRules || []) await requireOk(await fetch(
`${baseUrl}/api/levels/${state.id}/evidence-match-rules`, { method:'POST',headers,body:JSON.stringify(rule) }),
`Create evidence match rule ${rule.name}`)
for (const rule of manifest.evidenceSemanticRules || []) {
for (const rule of level.evidenceSemanticRules || []) {
const goalId = goalIds.get(rule.goalKey)
if (!goalId) throw new Error(`Semantic evidence rule ${rule.name} refers to unknown goal ${rule.goalKey}`)
await requireOk(await fetch(`${baseUrl}/api/levels/${state.id}/evidence-semantic-rules`, {
@@ -149,47 +163,72 @@ export async function importMysteryTemplate(manifestPath: string, baseUrl = 'htt
}), `Create semantic evidence rule ${rule.name}`)
}
const templateResponse = await requireOk(await fetch(`${baseUrl}/api/levels/${state.id}/templates?edit=1`, {
method: 'POST', headers, body: JSON.stringify({ slug: manifest.slug, name: manifest.name }),
}), 'Freeze mystery template')
method: 'POST', headers, body: JSON.stringify({ slug: level.slug, name: level.name || level.title }),
}), 'Freeze level template')
const template = await templateResponse.json() as { slug: string; currentVersion: number }
// Author the mystery and its NPC cast; the flow lives in the story graph, seeded below.
let mystery: { slug: string } | undefined
if (manifest.narrative) {
const mysteryResponse = await requireOk(await fetch(`${baseUrl}/api/mysteries?edit=1`, {
method: 'POST', headers, body: JSON.stringify({ slug: manifest.slug, title: manifest.title, cast: manifest.narrative.cast }),
}), 'Author narrative mystery')
mystery = await mysteryResponse.json() as { slug: string }
const playableId = `${level.slug}-case-${Date.now()}`
const playableResponse = await requireOk(await fetch(`${baseUrl}/api/templates/${level.slug}/levels?edit=1`, {
method: 'POST', headers, body: JSON.stringify({ id: playableId, title: level.title }),
}), 'Instantiate playable level')
const playable = await playableResponse.json() as CaseState
return { template, authoringLevelId: state.id, playableLevel: playable }
}
// Seed the story flow graph (default authored content that survives re-imports).
if (manifest.narrative.graph) {
// Author the narrative mystery (NPC cast) and seed its story flow graph.
async function importNarrative(baseUrl: string, slug: string, title: string, narrative: MysteryNarrative, headers: ImportHeaders, authorization?: string): Promise<{ slug: string }> {
const mysteryResponse = await requireOk(await fetch(`${baseUrl}/api/mysteries?edit=1`, {
method: 'POST', headers, body: JSON.stringify({ slug, title, cast: narrative.cast }),
}), 'Author narrative mystery')
const mystery = await mysteryResponse.json() as { slug: string }
if (narrative.graph) {
const listResponse = await requireOk(await fetch(`${baseUrl}/api/admin/mysteries`, { headers: authorization ? { authorization } : undefined }), 'List mysteries')
const mysteries = await listResponse.json() as { id: string; slug: string }[]
const mysteryId = mysteries.find(m => m.slug === manifest.slug)?.id
const mysteryId = mysteries.find(m => m.slug === slug)?.id
if (mysteryId) await requireOk(await fetch(`${baseUrl}/api/admin/mysteries/${mysteryId}/graph`, {
method: 'POST', headers, body: JSON.stringify(manifest.narrative.graph),
method: 'POST', headers, body: JSON.stringify(narrative.graph),
}), 'Seed story graph')
}
return mystery
}
const playableId = `${manifest.slug}-case-${Date.now()}`
const playableResponse = await requireOk(await fetch(`${baseUrl}/api/templates/${manifest.slug}/levels?edit=1`, {
method: 'POST', headers, body: JSON.stringify({ id: playableId, title: manifest.title }),
}), 'Instantiate playable mystery')
const playable = await playableResponse.json() as CaseState
return { manifest, template, mystery, authoringLevelId: state.id, playableLevel: playable }
// Legacy single-manifest import: one level plus an optional narrative in the same file.
export async function importMysteryTemplate(manifestPath: string, baseUrl = 'http://localhost:8787', adminJwt = process.env.OSINT_ADMIN_JWT) {
const absoluteManifest = path.resolve(manifestPath)
const manifest = JSON.parse(await readFile(absoluteManifest, 'utf8')) as MysteryManifest
const folderDir = path.dirname(absoluteManifest)
const { headers, authorization } = authHeaders(adminJwt)
const { narrative, ...level } = manifest
const result = await importLevel(baseUrl, folderDir, level, headers, authorization)
const mystery = narrative ? await importNarrative(baseUrl, manifest.slug, manifest.title, narrative, headers, authorization) : undefined
return { manifest, template: result.template, mystery, authoringLevelId: result.authoringLevelId, playableLevel: result.playableLevel }
}
// New self-contained import: a mystery folder (or single-file) with every level embedded.
// Accepts a directory (uses <dir>/mystery.json) or a manifest path; falls back to the
// legacy path when the file has no top-level `levels` array.
export async function importMystery(inputPath: string, baseUrl = 'http://localhost:8787', adminJwt = process.env.OSINT_ADMIN_JWT) {
const resolved = path.resolve(inputPath)
const manifestPath = (await stat(resolved)).isDirectory() ? path.join(resolved, 'mystery.json') : resolved
const parsed = JSON.parse(await readFile(manifestPath, 'utf8')) as MysteryFile | MysteryManifest
if (!Array.isArray((parsed as MysteryFile).levels)) return importMysteryTemplate(manifestPath, baseUrl, adminJwt)
const file = parsed as MysteryFile
const folderDir = path.dirname(manifestPath)
const { headers, authorization } = authHeaders(adminJwt)
const levels: LevelResult[] = []
for (const level of file.levels) levels.push(await importLevel(baseUrl, folderDir, level, headers, authorization))
const mystery = file.narrative ? await importNarrative(baseUrl, file.slug, file.title, file.narrative, headers, authorization) : undefined
return { slug: file.slug, title: file.title, levels, mystery }
}
const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : ''
if (invokedPath === fileURLToPath(import.meta.url)) {
const manifestPath = process.argv[2]
if (!manifestPath) throw new Error('Usage: npm run mystery:import -- <manifest.json>')
const result = await importMysteryTemplate(manifestPath, process.env.OSINT_BOARD_URL)
console.log(JSON.stringify({
template: `${result.template.slug}@v${result.template.currentVersion}`,
mystery: result.mystery ? result.mystery.slug : undefined,
authoringLevelId: result.authoringLevelId,
playableLevelId: result.playableLevel.id,
playUrl: `${process.env.OSINT_BOARD_URL || 'http://localhost:8787'}/`,
}, null, 2))
const inputPath = process.argv[2]
if (!inputPath) throw new Error('Usage: npm run mystery:import -- <mystery-folder | manifest.json>')
const result = await importMystery(inputPath, process.env.OSINT_BOARD_URL)
const playUrl = `${process.env.OSINT_BOARD_URL || 'http://localhost:8787'}/`
const summary = 'levels' in result
? { mystery: result.mystery?.slug, levels: result.levels.map(l => `${l.template.slug}@v${l.template.currentVersion}`), playUrl }
: { template: `${result.template.slug}@v${result.template.currentVersion}`, mystery: result.mystery?.slug, authoringLevelId: result.authoringLevelId, playableLevelId: result.playableLevel.id, playUrl }
console.log(JSON.stringify(summary, null, 2))
}
+1 -1
View File
@@ -35,7 +35,7 @@ const levels = createLevelRepository(pool, editingEnabled, objectStorage, eviden
const narrative = createNarrativeRepository(pool, objectStorage)
const storyGraph = createStoryGraphRepository(pool)
const users = createUserRepository(pool)
const AUTH_COOKIE = { httpOnly: true, sameSite: 'lax' as const, path: '/', maxAge: 30 * 24 * 60 * 60 * 1000 }
const AUTH_COOKIE = { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax' as const, path: '/', maxAge: 30 * 24 * 60 * 60 * 1000 }
const STORY_NODE_TYPES: StoryNodeType[] = ['cutscene', 'dialogue', 'level', 'det_gate', 'llm_gate', 'merit', 'phone']
function wantsEdit(req: express.Request) {
+18 -6
View File
@@ -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, normalizeCase, panViewport, projectThreadTag, threadCurve, threadTagLateralLimit, threadTagPlacement, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain'
import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, nextVisibleBoardPosition, normalizeCase, panViewport, projectThreadTag, threadCurve, threadTagLateralLimit, threadTagPlacement, timelinePositionPercent, timelineRange, 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'
@@ -381,13 +381,23 @@ export function App() {
const uploadFiles = useCallback(async (files: FileList | File[], source: 'file' | 'clipboard' = 'file') => {
if (!caseState) return
const queue = Array.from(files)
const boardScreen = boardRef.current?.getBoundingClientRect()
const screen = { width: boardScreen?.width || window.innerWidth, height: boardScreen?.height || window.innerHeight }
const portraitMobile = screen.width <= 900 && screen.height > screen.width
const insets = {
top: Math.min(140, screen.height * .28),
right: portraitMobile ? 68 : 18,
bottom: portraitMobile ? 18 : 68,
left: 18,
}
const reserved = [...caseState.exhibits]
setUploading(queue.length)
setDraggingFiles(false)
for (const [queueIndex, file] of queue.entries()) {
const position = nextOpenBoardPosition(caseState.exhibits, {
x: Math.max(100, (520 - caseState.viewport.x) / caseState.viewport.zoom) + queueIndex * 24,
y: Math.max(100, (310 - caseState.viewport.y) / caseState.viewport.zoom) + queueIndex * 24,
}, { width: 174, height: 145 })
for (const file of queue) {
// Reserve for the largest image presentation so choosing Clip, Image, or
// Mugshot in the following dialog cannot make it grow beyond the viewport.
const reservedSize = { width: 244, height: 294 }
const position = nextVisibleBoardPosition(reserved, caseState.viewport, screen, reservedSize, insets, { width: BOARD_W, height: BOARD_H })
const form = new FormData()
form.append('file', file)
form.append('x', String(position.x))
@@ -398,6 +408,7 @@ export function App() {
if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || `Upload failed (${response.status})`) }
const uploaded: UploadedCaseDocument = await response.json()
const { analysis, ...document } = uploaded
reserved.push({ ...document, ...position, ...reservedSize })
update(s => {
return { ...s, exhibits: [...s.exhibits, { ...document, ...position }] }
})
@@ -974,6 +985,7 @@ function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedEx
{documentExhibits(state.exhibits).filter(document => !document.hidden).map(document => { const membership = containmentRelations.find(relation => relation.toExhibitId === document.id); const folder = membership ? byId.get(membership.fromExhibitId) : undefined; const open = folder?.type !== 'folder' || folder.isOpen; const located = open && locatorDocumentId === document.id; const left = open ? document.x : folder.x + folder.width / 2 - document.width / 2, top = open ? document.y : folder.y + 45; const definition = documentWidget(document.fileType); const presentation=documentCapture(document.captureKind); const identification=mugshotIdentification(document,state.exhibits,state.connections); const Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; const missingProvenance=[!document.publishedAt ? 'DATE' : '',!document.sourceCitation?.trim() ? 'SOURCE' : ''].filter(Boolean); return <article key={document.id} data-document-locator-target={located ? document.id : undefined} data-temporal-id={`widget:${document.id}`} data-capture-kind={document.captureKind} data-identified-party-id={identification?.party.id} className={`source-file-widget ${open ? 'open' : 'closed'} file-type-${document.fileType} capture-kind-${document.captureKind} ${identification ? 'mugshot-identified' : ''} ${selected === document.id ? 'selected' : ''} ${located ? 'document-located' : ''} ${linkFrom === document.id ? 'linking' : ''} ${linkFrom && linkFrom !== document.id ? 'thread-target' : ''} ${arrivingExhibitIds.includes(document.id) ? 'arriving' : ''}`} style={{ left,top,width:document.width,height:document.height,rotate:`${document.rotation}deg`,zIndex:document.zIndex,'--clip-inset-rotation':`${clippingInsetRotation(document.id)}deg` } as React.CSSProperties}
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: 'widget', id: 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 (suppressClick.current) { suppressClick.current = false; return } if (!open) return; if (tool === 'move') onCardClick(document.id) }} onDoubleClick={() => open && !linkFrom && onOpenSource(document.id)}>
{document.captureKind === 'clipping' && <span className="clip-stamped-pin" aria-hidden="true"/>}
<header><span>{(document.captureKind === 'unclassified' ? definition.label : presentation.label).toUpperCase()}</span><i>{String(document.displayNumber || (membership?.sortOrder || 0) + 1).padStart(2, '0')}</i></header>
<div className="source-file-preview"><Preview document={document} source={source} onMemoryCue={cue => onUpdateDocumentCue(document.id, cue)}/></div>
{document.captureKind === 'photo' ? <MugshotCaption name={identification?.party.title || ''}/> : document.captureKind !== 'clipping' ? <strong>{document.title}</strong> : null}
+27
View File
@@ -9,6 +9,7 @@ import {
folderIsOpen,
moveBoardPoint,
nextOpenBoardPosition,
nextVisibleBoardPosition,
normalizeCase,
panViewport,
relationPosition,
@@ -121,6 +122,32 @@ describe('board coordinate math', () => {
{ x: 826, y: 400, width: 280, height: 160 },
], preferred, { width: 280 })).toEqual({ x: 174, y: 400 })
})
it('places new evidence within a panned and zoomed mobile viewport', () => {
const viewport = { x: -720, y: -310, zoom: .75 }
const screen = { width: 390, height: 658 }
const size = { width: 244, height: 294 }
const position = nextVisibleBoardPosition([], viewport, screen, size, { top: 120, right: 68, bottom: 18, left: 16 })
const screenLeft = viewport.x + position.x * viewport.zoom
const screenTop = viewport.y + position.y * viewport.zoom
expect(screenLeft).toBeGreaterThanOrEqual(16)
expect(screenTop).toBeGreaterThanOrEqual(120)
expect(screenLeft + size.width * viewport.zoom).toBeLessThanOrEqual(screen.width - 68)
expect(screenTop + size.height * viewport.zoom).toBeLessThanOrEqual(screen.height - 18)
})
it('keeps collision avoidance inside the currently visible board area', () => {
const viewport = { x: -400, y: -200, zoom: .5 }
const screen = { width: 800, height: 600 }
const size = { width: 174, height: 145 }
const first = nextVisibleBoardPosition([], viewport, screen, size)
const second = nextVisibleBoardPosition([{ ...first, ...size }], viewport, screen, size)
expect(second).not.toEqual(first)
expect(viewport.x + second.x * viewport.zoom).toBeGreaterThanOrEqual(0)
expect(viewport.y + second.y * viewport.zoom).toBeGreaterThanOrEqual(0)
expect(viewport.x + (second.x + size.width) * viewport.zoom).toBeLessThanOrEqual(screen.width)
expect(viewport.y + (second.y + size.height) * viewport.zoom).toBeLessThanOrEqual(screen.height)
})
})
describe('timeline projection', () => {
+65
View File
@@ -123,6 +123,71 @@ export function nextOpenBoardPosition(
return { x: Math.max(80, Math.min(bounds.width - size.width - 80, preferred.x)), y: Math.min(bounds.height - height - 80, preferred.y + evidence.length * 24) }
}
export interface BoardScreenSize { width: number; height: number }
export interface BoardScreenInsets { top?: number; right?: number; bottom?: number; left?: number }
/**
* Finds an open board position inside the portion of the canvas the player can
* currently see. Screen insets reserve space for board chrome such as the mobile
* tool rail. If the visible area is too small to contain the whole exhibit, the
* exhibit is centred so the largest useful portion remains on screen.
*/
export function nextVisibleBoardPosition(
exhibits: Pick<Exhibit, 'x' | 'y' | 'width' | 'height'>[],
viewport: Viewport,
screen: BoardScreenSize,
size: { width: number; height: number },
insets: BoardScreenInsets = {},
boardBounds = { width: 2400, height: 1500 },
) {
if (!Number.isFinite(viewport.zoom) || viewport.zoom <= 0) throw new RangeError('Board zoom must be positive')
const left = Math.max(0, insets.left || 0)
const top = Math.max(0, insets.top || 0)
const right = Math.max(0, insets.right || 0)
const bottom = Math.max(0, insets.bottom || 0)
const usableWidth = Math.max(1, screen.width - left - right)
const usableHeight = Math.max(1, screen.height - top - bottom)
const centre = {
x: (left + usableWidth / 2 - viewport.x) / viewport.zoom - size.width / 2,
y: (top + usableHeight / 2 - viewport.y) / viewport.zoom - size.height / 2,
}
const worldMinX = 40
const worldMinY = 40
const worldMaxX = Math.max(worldMinX, boardBounds.width - size.width - 40)
const worldMaxY = Math.max(worldMinY, boardBounds.height - size.height - 40)
const visibleMinX = Math.max(worldMinX, (left - viewport.x) / viewport.zoom)
const visibleMinY = Math.max(worldMinY, (top - viewport.y) / viewport.zoom)
const visibleMaxX = Math.min(worldMaxX, (screen.width - right - viewport.x) / viewport.zoom - size.width)
const visibleMaxY = Math.min(worldMaxY, (screen.height - bottom - viewport.y) / viewport.zoom - size.height)
const clamp = (value: number, minimum: number, maximum: number) => Math.max(minimum, Math.min(maximum, value))
// A whole exhibit may not fit at the current zoom on a very short landscape
// phone. In that case centring it gives the player the largest visible area.
const preferred = {
x: clamp(centre.x, worldMinX, worldMaxX),
y: clamp(centre.y, worldMinY, worldMaxY),
}
if (visibleMinX > visibleMaxX || visibleMinY > visibleMaxY) return preferred
preferred.x = clamp(preferred.x, visibleMinX, visibleMaxX)
preferred.y = clamp(preferred.y, visibleMinY, visibleMaxY)
const gap = 28
const overlaps = (x: number, y: number) => exhibits.some(item =>
x < item.x + item.width + gap && x + size.width + gap > item.x &&
y < item.y + item.height + gap && y + size.height + gap > item.y)
const axisCandidates = (origin: number, minimum: number, maximum: number, step: number) => {
const values = [origin, minimum, maximum]
const rings = Math.ceil((maximum - minimum) / step) + 1
for (let ring = 1; ring <= rings; ring += 1) values.push(clamp(origin + ring * step, minimum, maximum), clamp(origin - ring * step, minimum, maximum))
return [...new Set(values.map(value => Math.round(value * 1000) / 1000))]
}
const xs = axisCandidates(preferred.x, visibleMinX, visibleMaxX, size.width + 46)
const ys = axisCandidates(preferred.y, visibleMinY, visibleMaxY, size.height + 46)
const candidates = xs.flatMap(x => ys.map(y => ({ x, y }))).sort((a, b) =>
Math.hypot(a.x - preferred.x, a.y - preferred.y) - Math.hypot(b.x - preferred.x, b.y - preferred.y))
return candidates.find(candidate => !overlaps(candidate.x, candidate.y)) || preferred
}
export function panViewport(origin: Viewport, screenDelta: { x: number; y: number }): Viewport {
return { ...origin, x: origin.x + screenDelta.x, y: origin.y + screenDelta.y }
}
+2 -1
View File
@@ -221,7 +221,8 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.source-file-widget.capture-kind-scene .source-file-preview img { object-fit: cover; filter: saturate(.86) contrast(1.05); }
.source-file-widget.capture-kind-scene > strong { font-size: 11px; }
.source-file-widget.capture-kind-clipping { padding:14px 13px 10px;overflow:visible;background:linear-gradient(105deg,#e2dfcf,#d3d1c2);border:2px solid #b99a68;box-shadow:8px 10px 0 #020b0980,0 0 0 2px #526059; }
.source-file-widget.capture-kind-clipping::after { content:'';position:absolute;z-index:5;top:-7px;left:50%;width:13px;height:13px;translate:-50% 0;border-radius:50%;background:#9e392f;border:2px solid #5d1e1b;box-shadow:0 2px 2px #0006; }
.clip-stamped-pin { position:absolute;z-index:6;top:-7px;left:50%;width:24px;height:24px;translate:-50% 0;rotate:187deg;border:1px solid #5d5e59;border-radius:50%;background:conic-gradient(from 205deg,#73756f,#e5e4da 17%,#8c8e87 34%,#f5f2e5 49%,#74766f 67%,#c5c5bc 84%,#73756f);box-shadow:0 -3px 3px #0008,inset 1px 1px 1px #fff9,inset -2px -2px 2px #36393466;pointer-events:none; }
.clip-stamped-pin::before { content:'';position:absolute;left:7px;top:5px;width:10px;height:12px;background:#d9d6c7;clip-path:polygon(50% 100%,0 0,100% 0);filter:drop-shadow(0 1px 1px #5b584d88); }
.source-file-widget.capture-kind-clipping.open { transform:scale(1); }
.source-file-widget.capture-kind-clipping .source-file-preview { box-sizing:border-box;width:100%;height:210px;margin:3px 0 8px;border:2px solid #f4efe1;background:#c8c2b3;box-shadow:2px 3px 3px #10151170;transform:rotate(var(--clip-inset-rotation,.7deg));transform-origin:50% 48%; }
.source-file-widget.capture-kind-clipping .source-file-preview img { object-fit:contain;filter:grayscale(.12) contrast(1.08); }