feat: author first playable mystery
This commit is contained in:
+10
-5
@@ -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) },
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user