Compare commits
10
Commits
74ccd11d0c
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c099a5daa | ||
|
|
625f28b00b | ||
|
|
9e426e91ab | ||
|
|
f6fbeb39cf | ||
|
|
ce30321532 | ||
|
|
ada26ec40a | ||
|
|
1a9b6124c2 | ||
|
|
5eab67dd5d | ||
|
|
072e82f253 | ||
|
|
37c9fa1ebe |
@@ -0,0 +1,46 @@
|
||||
# GUPI production environment TEMPLATE. Copy to /opt/gupi/.env.prod on the server and
|
||||
# fill in real values there (never commit real secrets). GUPI runs ONLY its own app
|
||||
# container; Postgres + MinIO come from the shared gu_common stack (must already be up).
|
||||
#
|
||||
# Endpoints:
|
||||
# - Public (frontend, TLS via the proxy): https://gupi.glitch.university
|
||||
# - Internal (app -> services, plain HTTP): docker hostnames gnommo-db / gnommo-minio
|
||||
# (those hostnames live in docker-compose.prod.yml; here you only set credentials).
|
||||
#
|
||||
# Deploy with: ./deploy.sh (server-side it runs, from /opt/gupi:)
|
||||
# docker compose -f /opt/gupi/docker-compose.prod.yml --env-file /opt/gupi/.env.prod up -d
|
||||
|
||||
# --- Shared PostgreSQL (gu_common; reachable as gnommo-db on the shared network) ---
|
||||
POSTGRES_USER=gupi
|
||||
POSTGRES_PASSWORD=CHANGE_ME
|
||||
POSTGRES_DB=gupi
|
||||
|
||||
# --- Shared MinIO / S3 (gu_common; reachable as gnommo-minio) ---
|
||||
MINIO_ROOT_USER=CHANGE_ME
|
||||
MINIO_ROOT_PASSWORD=CHANGE_ME
|
||||
S3_REGION=us-east-1
|
||||
OSINT_S3_BUCKET=gupi-osint
|
||||
|
||||
# --- App ---
|
||||
# DOMAIN drives the default public origin (https://gupi.${DOMAIN}); override CORS_ORIGIN
|
||||
# directly if the frontend is served somewhere else.
|
||||
DOMAIN=glitch.university
|
||||
# CORS_ORIGIN=https://gupi.glitch.university
|
||||
JWT_SECRET=CHANGE_ME_LONG_RANDOM_SECRET
|
||||
LEVEL_EDITING_ENABLED=false
|
||||
MAX_DOCUMENT_BYTES=26214400
|
||||
|
||||
# --- OCR (evidence text extraction) ---
|
||||
OCR_ENABLED=true
|
||||
OCR_LANGUAGES=nor+eng
|
||||
OCR_TIMEOUT_MS=20000
|
||||
MAX_OCR_BYTES=15728640
|
||||
MAX_EXTRACTED_TEXT_CHARACTERS=200000
|
||||
|
||||
# --- Evidence LLM judge (optional; disabled by default) ---
|
||||
EVIDENCE_JUDGE_PROVIDER=disabled
|
||||
EVIDENCE_JUDGE_MODEL=
|
||||
EVIDENCE_JUDGE_VERSION=evidence_claim_v1
|
||||
EVIDENCE_JUDGE_TIMEOUT_MS=10000
|
||||
EVIDENCE_JUDGE_MAX_CHARACTERS=20000
|
||||
ANTHROPIC_API_KEY=
|
||||
@@ -1,6 +1,9 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.env.prod
|
||||
.env.local
|
||||
.env.*.local
|
||||
.DS_Store
|
||||
*.tsbuildinfo
|
||||
playwright-report/
|
||||
|
||||
@@ -9,8 +9,9 @@ if [ "$1" = "--skip-pull" ]; then
|
||||
fi
|
||||
|
||||
SERVER="${DEPLOY_SERVER:-root@76.13.144.52}"
|
||||
REMOTE_DIR="${DEPLOY_DIR:-/opt/osint-board}"
|
||||
COMPOSE="docker compose -f ${REMOTE_DIR}/docker-compose.prod.yml --env-file /opt/gu_common/.env.prod"
|
||||
REMOTE_DIR="${DEPLOY_DIR:-/opt/gupi}"
|
||||
# GUPI runs from its own dir with its own env; the shared services come from gu_common.
|
||||
COMPOSE="docker compose -f ${REMOTE_DIR}/docker-compose.prod.yml --env-file ${REMOTE_DIR}/.env.prod"
|
||||
|
||||
TARGET_HOST=$(echo "${SERVER}" | sed 's/.*@//')
|
||||
OWN_IP=$(curl -sf --max-time 3 ifconfig.me 2>/dev/null || echo "unknown")
|
||||
@@ -54,8 +55,8 @@ rsync -avz --delete \
|
||||
--exclude '.DS_Store' \
|
||||
./ "${SERVER}:${REMOTE_DIR}/"
|
||||
|
||||
echo "==> Verifying gu_common configuration..."
|
||||
ssh "$SERVER" "test -f /opt/gu_common/.env.prod || { echo 'ERROR: /opt/gu_common/.env.prod is missing'; exit 1; }"
|
||||
echo "==> Verifying GUPI environment..."
|
||||
ssh "$SERVER" "test -f ${REMOTE_DIR}/.env.prod || { echo 'ERROR: ${REMOTE_DIR}/.env.prod is missing (copy .env.prod.example and fill it in)'; exit 1; }"
|
||||
|
||||
echo "==> Ensuring shared network exists..."
|
||||
ssh "$SERVER" "docker network create gnommo 2>/dev/null || true"
|
||||
@@ -97,4 +98,4 @@ for i in $(seq 1 24); do
|
||||
fi
|
||||
done
|
||||
|
||||
echo "==> Done! https://osint.glitch.university"
|
||||
echo "==> Done! https://gupi.glitch.university"
|
||||
|
||||
@@ -8,8 +8,10 @@ services:
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 8787
|
||||
# Internal: reach gu_common services by their docker hostnames over plain HTTP.
|
||||
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@gnommo-db:5432/${POSTGRES_DB}
|
||||
CORS_ORIGIN: https://osint.${DOMAIN}
|
||||
# Public: the frontend is served over TLS at gupi.glitch.university by the proxy.
|
||||
CORS_ORIGIN: ${CORS_ORIGIN:-https://gupi.${DOMAIN}}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
LEVEL_EDITING_ENABLED: ${LEVEL_EDITING_ENABLED:-false}
|
||||
MAX_DOCUMENT_BYTES: ${MAX_DOCUMENT_BYTES:-26214400}
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
# Narrative layer: campaigns, NPC cutscenes, and authoring
|
||||
|
||||
This roadmap covers the narrative layer end to end: the **campaign** that chains
|
||||
levels into a mystery, the **NPCs / poses / cutscenes** the player watches
|
||||
between levels, and the **admin authoring panel** that lets a game designer build
|
||||
all of it in-app. Work generally proceeds top to bottom.
|
||||
|
||||
**Design intent.** These mysteries are real-world scam cases that must actually be
|
||||
investigated to be understood. Such cases cannot be simplified, they
|
||||
can only be staged for didactic discovery into levels.
|
||||
|
||||
The LLM is not a decoration on the cutscenes — it
|
||||
is a **cognitive shim**: the assistant that keeps a player oriented as case
|
||||
complexity grows. A player can use this feature many times, but some very bright
|
||||
players might get it right on the first go.
|
||||
This is what lets a mystery be as intricate as the real case
|
||||
demands without the player getting lost. The scripted narrative layer below is the
|
||||
delivery channel and the fallback; the LLM is the layer that scales comprehension.
|
||||
Section 9 is deferred in build order but primary in intent, so the model is shaped
|
||||
now to accommodate it (the `generated` step kind, the read-only context contract,
|
||||
and the authored case model).
|
||||
|
||||
## Working agreement
|
||||
|
||||
- All narrative content — campaigns, NPCs, poses, cutscenes, dialogue text — is
|
||||
authored template data frozen through supported operations. None of it is
|
||||
hard-coded into React components or SQL seed literals. The exception to this rule is
|
||||
custom cutscenes (react components) which are registered as components and referenced by the node.
|
||||
- The admin panel is a GUI over the **same** operations available to the manifest
|
||||
importer; both paths freeze the same immutable template data.
|
||||
- A cutscene never mutates a board. The professor's scene *narrates* the new
|
||||
document and goal; those exhibits and the updated brief already live in the
|
||||
next chapter's template.
|
||||
- A narrative behavior is complete only when its PostgreSQL representation, API
|
||||
behavior, frontend presentation, persistence, and focused tests agree.
|
||||
- Pose portraits are immutable shared bytes, stored and cloned exactly like
|
||||
document image assets (MinIO + `objectStorage`); scenes reference assets, they
|
||||
never duplicate them.
|
||||
|
||||
## First vertical slice: "The Glass Harbour Diversion"
|
||||
|
||||
Build the smallest end-to-end narrative loop before generalizing. Ship these in
|
||||
order; each is playable on its own.
|
||||
|
||||
- [ ] Add the **splash screen** — "PRINCIPAL INVESTIGATOR", Glitch University —
|
||||
with a single **New Game** action that creates a playthrough and launches the
|
||||
first scene (sections 1, 6, 7).
|
||||
- [ ] Create a mystery named **The Glass Harbour Diversion** as a one-chapter
|
||||
campaign wrapping the existing Glass Harbor level template (sections 1–2).
|
||||
- [ ] Add a **briefing NPC** (the Glitch University professor) and a
|
||||
`mystery_intro` cutscene that briefs the player, using at least two poses to
|
||||
prove pose-per-utterance (sections 2–3, 7).
|
||||
- [ ] Wire the briefing to play once on load and mark itself seen, then reveal the
|
||||
first level's board (sections 1, 6, 7).
|
||||
- [ ] Author **two `level_debrief` end-scenes** the player reaches by reporting
|
||||
back: one that **sends them back to the board** and one that **concludes the
|
||||
mystery**. Model these as two end-of-scene outcomes (buttons), not branching.
|
||||
- [ ] Leave the back-to-board scene as **scripted** for now, but author it as a
|
||||
`generated`-ready step (section 9) so the professor's hint can later be produced
|
||||
from the player's Case Report explanation.
|
||||
|
||||
## 1. Campaign / progression backbone
|
||||
|
||||
- [ ] Define a **cutscene** node as something a) references a custom react component.
|
||||
That react can use potential **utterances** such that for example, it can play
|
||||
an ordered sequence utterance that brief the player: `(speaker NPC, pose, text)`.
|
||||
A node can be marked has_utterances which permits the admin user to add utterances in order.
|
||||
[ ] A dialogue is another type of node that invokes the standard NPC dialogue component. This
|
||||
has utterances, and consist of a graph where utterances either are spoken by the NPC or available for selection.
|
||||
Example : if the NPC utters "Are you ready?" this has two child utterances marked "player" which could be "yes" and no. The user may select these. "No" could in principle point back to the same utterance and "yes" to the next. If an utterance has a non NULL terminal id, then the game advances to the node pointed to by that terminal. Available terminals are only those who have the current dialogue node as it parent.
|
||||
- [ ] There exists "det_gate" nodes and "llm_gate" nodes. We begin with the deterministic gate only. The end result of a level is sent to a "det_gate". The det gate can inspect the output of the level and determine if the story should advance through one of its terminals. For now, the det-gate always returns the happy path terminal leading to the mystery being solved.
|
||||
|
||||
## 2. NPC and pose catalog
|
||||
|
||||
- [ ] Add an **NPC** entity (display name, short role e.g. "Glitch University
|
||||
professor", default pose) owned by the mystery/template family, so casts are
|
||||
authored rather than global magic strings.
|
||||
- [ ] Add **poses** as named portrait variants of an NPC (`pose_key` such as
|
||||
`neutral`, `concerned`, `wry`, `pointing`), each backed by one immutable image
|
||||
asset via the existing `assets` table + MinIO path.
|
||||
- [ ] Clone NPCs and pose→asset references (asset bytes reused, not copied) during
|
||||
template freeze and instantiation, mirroring document image cloning.
|
||||
- [ ] Enforce that every dialogue step names an NPC that exists in the mystery's
|
||||
cast; a **missing pose is never an error**.
|
||||
- [ ] Resolve a step's portrait at render time with graceful fallback: the
|
||||
requested `pose_key`, else the NPC's **`default`** pose, else **no artwork**
|
||||
(speaker name + text only). This lets authors add poses incrementally and keeps
|
||||
the first slice playable with zero uploaded art.
|
||||
|
||||
## 6. API contract
|
||||
|
||||
- [ ] **New Game / session:** add `POST …/playthroughs` (create for the current
|
||||
`user_id`, per section 1) and `GET …/playthroughs/current` so the splash can
|
||||
offer New Game or Resume; scope every playthrough read/write to the caller's
|
||||
`user_id` so one player cannot touch another's game state.
|
||||
- [ ] **Play mode:** return a compact `pendingCutscene` payload (slot, ordered
|
||||
steps with resolved NPC name + pose asset URL) when one is due and unseen; add
|
||||
`POST …/playthroughs/:id/cutscenes/:cutsceneId/seen` (idempotent) and
|
||||
`POST …/playthroughs/:id/advance` implementing section 1's transactional advance.
|
||||
- [ ] **Admin mode:** add authenticated CRUD for mysteries, chapter ordering,
|
||||
NPCs and poses, and dialogue scenes/steps, plus the campaign freeze operation —
|
||||
all behind the existing admin JWT and `edit=1` gate.
|
||||
- [ ] Keep authoring-only fields (raw pose keys, expected-solution data, unfrozen
|
||||
drafts) out of play-mode responses, consistent with how brief concept
|
||||
`expectedPartyKind` is already hidden in play mode.
|
||||
|
||||
|
||||
## Definition of done
|
||||
|
||||
A game designer can, in the admin panel, create a mystery, select existing levels
|
||||
as ordered chapters, create NPCs and upload named poses, craft dialogue scenes
|
||||
choosing a pose per step, and attach those scenes to chapter slots — then freeze
|
||||
it. A player lands on the "PRINCIPAL INVESTIGATOR" splash, chooses New Game to
|
||||
create a playthrough bound to their identity, watches the professor speak
|
||||
line-by-line with a
|
||||
changing portrait, investigates each board, reports back to advance a chapter that
|
||||
introduces a new document and goal, and reloads at any point without replaying
|
||||
seen scenes — with no dialogue text hard-coded in React and no cutscene mutating a
|
||||
board.
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user