feat: author first playable mystery

This commit is contained in:
2026-08-14 15:02:00 +02:00
parent d46a425401
commit b284275e98
13 changed files with 413 additions and 11 deletions
+10 -5
View File
@@ -1,7 +1,7 @@
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 { clampBoardZoom, containedIds, dateValue, folderIsOpen, moveBoardPoint, normalizeCase, panViewport, relationPosition, timelinePositionPercent, timelineRange, zoomFromWheel } from './boardDomain'
import { clampBoardZoom, containedIds, dateValue, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, relationPosition, timelinePositionPercent, timelineRange, zoomFromWheel } from './boardDomain'
import { documentWidget, exhibitWidget } from './exhibitRegistry'
const BOARD_W = 2400
@@ -107,16 +107,17 @@ export function App() {
const content = window.prompt('What do you think this evidence means?')?.trim()
if (!content || !caseState) return
const { viewport } = caseState
const note: Evidence = { id: uid('note'), type: 'note', title: 'WORKING NOTE', content, x: Math.max(100, (500 - viewport.x) / viewport.zoom), y: Math.max(100, (330 - viewport.y) / viewport.zoom), width: 108 }
const position = nextOpenBoardPosition(caseState.evidence, { x: Math.max(100, (500 - viewport.x) / viewport.zoom), y: Math.max(100, (330 - viewport.y) / viewport.zoom) }, { width: 108 })
const note: Evidence = { id: uid('note'), type: 'note', title: 'WORKING NOTE', content, ...position, width: 108 }
update(s => ({ ...s, evidence: [...s.evidence, note] })); setSelected(note.id)
}
const addEvent = () => {
if (!caseState) return
const { viewport } = caseState
const position = nextOpenBoardPosition(caseState.evidence, { x: Math.max(100, (620 - viewport.x) / viewport.zoom), y: Math.max(100, (290 - viewport.y) / viewport.zoom) }, { width: 270 })
const event: Evidence = { id: uid('event'), type: 'event', title: 'UNTITLED EVENT', content: 'Describe what happened.',
eventDate: new Date().toISOString(), supportingEvidenceIds: [], x: Math.max(100, (620 - viewport.x) / viewport.zoom),
y: Math.max(100, (290 - viewport.y) / viewport.zoom), width: 270 }
eventDate: new Date().toISOString(), supportingEvidenceIds: [], ...position, width: 270 }
update(state => ({ ...state, evidence: [...state.evidence, event] }))
setSelected(event.id); setEditingEventId(event.id)
}
@@ -128,9 +129,13 @@ export function App() {
const existingId = concept.resolvedPartyExhibitId
const partyId = existingId || uid('party')
const { viewport } = caseState
const existingParty = caseState.evidence.find(item => item.id === existingId)
const position = existingParty ? { x: existingParty.x, y: existingParty.y } : nextOpenBoardPosition(caseState.evidence, {
x: Math.max(100, (670 - viewport.x) / viewport.zoom), y: Math.max(100, (310 - viewport.y) / viewport.zoom),
}, { width: 280 })
const party: Evidence = { id: partyId, type: 'party', partyKind, organizationKind: partyKind === 'organization' ? 'business' : undefined,
title: concept.label, content: concept.context, aliases: [], relatedEvidenceIds: [],
x: Math.max(100, (670 - viewport.x) / viewport.zoom), y: Math.max(100, (310 - viewport.y) / viewport.zoom), width: 280 }
...position, width: 280 }
update(state => ({ ...state,
evidence: existingId ? state.evidence.map(item => item.id === existingId ? { ...item, partyKind, organizationKind: partyKind === 'organization' ? item.organizationKind || 'business' : undefined } : item) : [...state.evidence, party],
brief: { ...state.brief, concepts: state.brief.concepts.map(item => item.id === conceptId ? { ...item, resolvedPartyExhibitId: partyId } : item) },
+11
View File
@@ -7,6 +7,7 @@ import {
containedIds,
folderIsOpen,
moveBoardPoint,
nextOpenBoardPosition,
normalizeCase,
panViewport,
relationPosition,
@@ -58,6 +59,16 @@ describe('board coordinate math', () => {
expect(zoomFromWheel(1, 10_000)).toBe(MIN_BOARD_ZOOM)
expect(zoomFromWheel(1, -10_000)).toBe(MAX_BOARD_ZOOM)
})
it('places new exhibits in deterministic open slots', () => {
const preferred = { x: 500, y: 400 }
expect(nextOpenBoardPosition([], preferred, { width: 280 })).toEqual(preferred)
expect(nextOpenBoardPosition([{ x: 500, y: 400, width: 280 }], preferred, { width: 280 })).toEqual({ x: 826, y: 400 })
expect(nextOpenBoardPosition([
{ x: 500, y: 400, width: 280 },
{ x: 826, y: 400, width: 280 },
], preferred, { width: 280 })).toEqual({ x: 174, y: 400 })
})
})
describe('timeline projection', () => {
+23
View File
@@ -16,6 +16,29 @@ export function moveBoardPoint(origin: { x: number; y: number }, screenDelta: {
return { x: origin.x + screenDelta.x / zoom, y: origin.y + screenDelta.y / zoom }
}
export function nextOpenBoardPosition(
evidence: Pick<Evidence, 'x' | 'y' | 'width'>[],
preferred: { x: number; y: number },
size: { width: number; height?: number },
bounds = { width: 2400, height: 1500 },
) {
const height = size.height || 160
const gap = 28
const overlaps = (x: number, y: number) => evidence.some(item =>
x < item.x + item.width + gap && x + size.width + gap > item.x &&
y < item.y + 160 + gap && y + height + gap > item.y)
const xStep = size.width + 46
const yStep = height + 46
for (let row = 0; row < 7; row += 1) {
for (const column of [0, 1, -1, 2, -2, 3, -3]) {
const x = Math.max(80, Math.min(bounds.width - size.width - 80, preferred.x + column * xStep))
const y = Math.max(100, Math.min(bounds.height - height - 80, preferred.y + row * yStep))
if (!overlaps(x, y)) return { x, y }
}
}
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 function panViewport(origin: Viewport, screenDelta: { x: number; y: number }): Viewport {
return { ...origin, x: origin.x + screenDelta.x, y: origin.y + screenDelta.y }
}