From 260f439b23ab0d4eb5a6e4a4392b708fc6a884f8 Mon Sep 17 00:00:00 2001 From: uc-hoba Date: Wed, 10 Jun 2026 12:09:10 +0800 Subject: [PATCH] feat: add interactive street design flow and upload result --- src/app/actions/upload-result.ts | 46 +++++ src/app/globals.css | 5 + src/app/layout.tsx | 4 +- src/app/manifest.ts | 7 +- src/app/page.tsx | 125 +++++++------- src/components/flow/character-step.tsx | 52 ++++++ src/components/flow/learn-step.tsx | 25 +++ src/components/flow/qr-code.tsx | 47 +++++ src/components/flow/remodel-step.tsx | 136 +++++++++++++++ src/components/flow/result-step.tsx | 106 ++++++++++++ src/components/flow/riding-step.tsx | 23 +++ src/components/flow/settings-panel.tsx | 85 +++++++++ src/components/flow/standby-step.tsx | 34 ++++ src/hooks/use-flow-sync.ts | 54 ++++++ src/lib/capture.ts | 15 ++ src/lib/flow.ts | 227 +++++++++++++++++++++++++ src/lib/utils.ts | 2 +- src/stores/flow-store.ts | 94 ++++++++++ src/stores/settings-store.ts | 21 +++ 19 files changed, 1044 insertions(+), 64 deletions(-) create mode 100644 src/app/actions/upload-result.ts create mode 100644 src/components/flow/character-step.tsx create mode 100644 src/components/flow/learn-step.tsx create mode 100644 src/components/flow/qr-code.tsx create mode 100644 src/components/flow/remodel-step.tsx create mode 100644 src/components/flow/result-step.tsx create mode 100644 src/components/flow/riding-step.tsx create mode 100644 src/components/flow/settings-panel.tsx create mode 100644 src/components/flow/standby-step.tsx create mode 100644 src/hooks/use-flow-sync.ts create mode 100644 src/lib/capture.ts create mode 100644 src/lib/flow.ts create mode 100644 src/stores/flow-store.ts diff --git a/src/app/actions/upload-result.ts b/src/app/actions/upload-result.ts new file mode 100644 index 0000000..af5d78b --- /dev/null +++ b/src/app/actions/upload-result.ts @@ -0,0 +1,46 @@ +'use server'; + +import { uploadBuffer } from '@/lib/ftps'; + +const MIME_EXT: Record = { + 'image/png': '.png', + 'image/jpeg': '.jpg', + 'image/webp': '.webp', +}; + +export interface UploadResult { + ok: boolean; + url?: string; + error?: string; +} + +/** + * Receives a captured result image and uploads it to the customer FTPS server. + * Invoked from the client (ResultStep) via an event handler, not a form. + * + * Runs on the Node.js runtime (standalone server) — required by `basic-ftp`. + */ +export async function uploadResultImage( + formData: FormData, +): Promise { + try { + const image = formData.get('image'); + if (!(image instanceof File)) { + return { ok: false, error: 'missing image' }; + } + + const ext = MIME_EXT[image.type] ?? '.png'; + const buffer = Buffer.from(await image.arrayBuffer()); + const url = await uploadBuffer(buffer, ext); + + // TODO: HEAD-verify the public URL is reachable before returning, so the QR + // never points at a not-yet-served file (architecture.md §5.1). + + return { ok: true, url }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : 'upload failed', + }; + } +} diff --git a/src/app/globals.css b/src/app/globals.css index 2f61459..16cfefd 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -123,6 +123,11 @@ } body { @apply bg-background text-foreground; + /* Kiosk: no rubber-band scroll, no text selection, snappy touch */ + overscroll-behavior: none; + -webkit-user-select: none; + user-select: none; + touch-action: manipulation; } html { @apply font-sans; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index dc40473..f2f0997 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -18,8 +18,8 @@ const customMono = Custom_Mono({ }); export const metadata: Metadata = { - title: 'Create Next App', - description: 'Generated by create next app', + title: 'Ready Set Ride', + description: '為城市設計自行車道路的互動展件', }; export default function RootLayout({ diff --git a/src/app/manifest.ts b/src/app/manifest.ts index e489e4a..1ba3975 100644 --- a/src/app/manifest.ts +++ b/src/app/manifest.ts @@ -2,11 +2,12 @@ import type { MetadataRoute } from 'next'; export default function manifest(): MetadataRoute.Manifest { return { - name: 'Next.js App', - short_name: 'Next.js App', - description: 'Next.js App', + name: 'Ready Set Ride', + short_name: 'Ready Set Ride', + description: '為城市設計自行車道路的互動展件', start_url: '/', display: 'standalone', + orientation: 'portrait', background_color: '#fff', theme_color: '#fff', icons: [ diff --git a/src/app/page.tsx b/src/app/page.tsx index 8b31f77..4f147ae 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,65 +1,74 @@ -import Image from 'next/image'; +'use client'; + +import { useState } from 'react'; +import { CharacterStep } from '@/components/flow/character-step'; +import { LearnStep } from '@/components/flow/learn-step'; +import { RemodelStep } from '@/components/flow/remodel-step'; +import { ResultStep } from '@/components/flow/result-step'; +import { RidingStep } from '@/components/flow/riding-step'; +import { SettingsPanel } from '@/components/flow/settings-panel'; +import { StandbyStep } from '@/components/flow/standby-step'; +import { useFlowSync } from '@/hooks/use-flow-sync'; +import { useIdle } from '@/hooks/use-idle'; +import { STEP, STEP_NAMES, type Step } from '@/lib/flow'; +import { useFlowStore } from '@/stores/flow-store'; +import { useSettingsStore } from '@/stores/settings-store'; + +function StepView({ + step, + onOpenSettings, +}: { + step: Step; + onOpenSettings: () => void; +}) { + switch (step) { + case STEP.STANDBY: + return ; + case STEP.CHARACTER: + return ; + case STEP.LEARN: + return ; + case STEP.REMODEL: + return ; + case STEP.RIDING: + return ; + case STEP.RESULT: + return ; + } +} export default function Home() { + const hasHydrated = useSettingsStore((s) => s.hasHydrated); + const devMode = useSettingsStore((s) => s.devMode); + const idleSeconds = useSettingsStore((s) => s.idleSeconds); + + const step = useFlowStore((s) => s.step); + const reset = useFlowStore((s) => s.reset); + + const { connectionStatus } = useFlowSync(); + const [settingsOpen, setSettingsOpen] = useState(false); + + // Idle timeout only on the interactive steps (1-3) → back to standby. + const idleActive = step >= STEP.CHARACTER && step <= STEP.REMODEL; + useIdle(idleActive ? idleSeconds * 1000 : 0, reset); + + if (!hasHydrated) { + return
; + } + return ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{' '} - - Templates - {' '} - or the{' '} - - Learning - {' '} - center. -

-
- +
+
+ setSettingsOpen(true)} />
+ + {devMode && ( +
+ step {step} · {STEP_NAMES[step]} · mqtt {connectionStatus} +
+ )} + + {settingsOpen && setSettingsOpen(false)} />}
); } diff --git a/src/components/flow/character-step.tsx b/src/components/flow/character-step.tsx new file mode 100644 index 0000000..a7a57ed --- /dev/null +++ b/src/components/flow/character-step.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { CHARACTERS, STEP } from '@/lib/flow'; +import { cn } from '@/lib/utils'; +import { useFlowStore } from '@/stores/flow-store'; + +export function CharacterStep() { + const characterId = useFlowStore((s) => s.characterId); + const selectCharacter = useFlowStore((s) => s.selectCharacter); + const setStep = useFlowStore((s) => s.setStep); + + return ( +
+

+ 選一位想幫他規劃道路的角色 +

+ +
+ {CHARACTERS.map((c) => { + const active = characterId === c.id; + return ( + + ); + })} +
+ + +
+ ); +} diff --git a/src/components/flow/learn-step.tsx b/src/components/flow/learn-step.tsx new file mode 100644 index 0000000..afa0093 --- /dev/null +++ b/src/components/flow/learn-step.tsx @@ -0,0 +1,25 @@ +'use client'; + +import { STEP } from '@/lib/flow'; +import { useFlowStore } from '@/stores/flow-store'; + +export function LearnStep() { + const setStep = useFlowStore((s) => s.setStep); + + return ( +
+

請看上方螢幕

+

+ 上方螢幕正在播放街道介紹,看完後就開始改造這條街吧。 +

+ + +
+ ); +} diff --git a/src/components/flow/qr-code.tsx b/src/components/flow/qr-code.tsx new file mode 100644 index 0000000..0d90d64 --- /dev/null +++ b/src/components/flow/qr-code.tsx @@ -0,0 +1,47 @@ +'use client'; + +import { useEffect, useRef } from 'react'; + +interface Props { + data: string; + size?: number; +} + +/** + * Renders a styled QR code for `data`. `qr-code-styling` touches the DOM, so it + * is imported lazily inside the effect to stay clear of SSR. + */ +export function QrCode({ data, size = 240 }: Props) { + const containerRef = useRef(null); + // biome-ignore lint/suspicious/noExplicitAny: qr-code-styling instance type loaded lazily + const instanceRef = useRef(null); + + // biome-ignore lint/correctness/useExhaustiveDependencies: initial data is read once here; later changes go through update() in the next effect + useEffect(() => { + let cancelled = false; + (async () => { + const { default: QRCodeStyling } = await import('qr-code-styling'); + if (cancelled) return; + instanceRef.current = new QRCodeStyling({ + width: size, + height: size, + type: 'svg', + data, + dotsOptions: { type: 'rounded' }, + }); + if (containerRef.current) { + containerRef.current.replaceChildren(); + instanceRef.current.append(containerRef.current); + } + })(); + return () => { + cancelled = true; + }; + }, [size]); + + useEffect(() => { + instanceRef.current?.update({ data }); + }, [data]); + + return
; +} diff --git a/src/components/flow/remodel-step.tsx b/src/components/flow/remodel-step.tsx new file mode 100644 index 0000000..1f07ffe --- /dev/null +++ b/src/components/flow/remodel-step.tsx @@ -0,0 +1,136 @@ +'use client'; + +import { + COMPONENTS, + getComponent, + MIN_COMPONENT_TYPES, + STEP, + WIDTH_MAX, + WIDTH_MIN, +} from '@/lib/flow'; +import { useFlowStore } from '@/stores/flow-store'; + +export function RemodelStep() { + const placed = useFlowStore((s) => s.placed); + const addComponent = useFlowStore((s) => s.addComponent); + const removeComponent = useFlowStore((s) => s.removeComponent); + const moveComponent = useFlowStore((s) => s.moveComponent); + const setWidth = useFlowStore((s) => s.setWidth); + const setStep = useFlowStore((s) => s.setStep); + + const typeCount = new Set(placed.map((p) => p.key)).size; + const ready = typeCount >= MIN_COMPONENT_TYPES; + + return ( +
+
+

+ 街道元件 +

+
+ {COMPONENTS.map((c) => ( + + ))} +
+
+ +
+

+ 你的街道({placed.length} 個 · {typeCount} 種) +

+
+ {placed.length === 0 ? ( +

+ 點上方元件加入街道 +

+ ) : ( + placed.map((p, i) => { + const meta = getComponent(p.key); + return ( +
+ + {i + 1} + +
+ + +
+ {meta.name} + + {meta.isRoad ? ( + + ) : ( + + 非道路 + + )} + + +
+ ); + }) + )} +
+
+ + {ready ? ( + + ) : ( +

+ 再選 {MIN_COMPONENT_TYPES - typeCount} 種不同元件即可出發 +

+ )} +
+ ); +} diff --git a/src/components/flow/result-step.tsx b/src/components/flow/result-step.tsx new file mode 100644 index 0000000..5cfbe44 --- /dev/null +++ b/src/components/flow/result-step.tsx @@ -0,0 +1,106 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { uploadResultImage } from '@/app/actions/upload-result'; +import { captureNode } from '@/lib/capture'; +import { getCharacter, randomTitle, type Title } from '@/lib/flow'; +import { useFlowStore } from '@/stores/flow-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { QrCode } from './qr-code'; + +type UploadStatus = 'pending' | 'ready' | 'error'; + +// Hard ceiling: never let the kiosk get stuck on the result screen if the +// upload hangs (architecture.md §5.3). +const MAX_WAIT_MS = 30_000; + +export function ResultStep() { + const characterId = useFlowStore((s) => s.characterId); + const result = useFlowStore((s) => s.result); + const setResult = useFlowStore((s) => s.setResult); + const setTitle = useFlowStore((s) => s.setTitle); + const reset = useFlowStore((s) => s.reset); + + const resultSeconds = useSettingsStore((s) => s.resultSeconds); + const devMode = useSettingsStore((s) => s.devMode); + + const cardRef = useRef(null); + const startedRef = useRef(false); + + // TODO: swap randomTitle() for decideTitle(placed) once the rule is final. + const [title] = useState(() => randomTitle()); + const [status, setStatus] = useState<UploadStatus>('pending'); + + const character = getCharacter(characterId); + + useEffect(() => { + setTitle(title.key); + }, [setTitle, title.key]); + + // Capture + upload once. + useEffect(() => { + if (startedRef.current) return; + startedRef.current = true; + + (async () => { + try { + if (!cardRef.current) throw new Error('nothing to capture'); + const blob = await captureNode(cardRef.current); + const form = new FormData(); + form.append('image', blob, 'result.png'); + const res = await uploadResultImage(form); + if (!res.ok || !res.url) throw new Error(res.error ?? 'upload failed'); + setResult(res.url); + setStatus('ready'); + } catch { + // Dev fallback so the QR + countdown can be exercised without a working + // capture/upload pipeline yet. + if (devMode) setResult('https://dl.giant.com.tw/MQR/demo.png'); + setStatus('error'); + } + })(); + }, [setResult, devMode]); + + // Return to standby: after the upload settles, or at the latest MAX_WAIT_MS. + useEffect(() => { + const settled = status !== 'pending'; + const delay = settled ? resultSeconds * 1000 : MAX_WAIT_MS; + const timer = setTimeout(reset, delay); + return () => clearTimeout(timer); + }, [status, resultSeconds, reset]); + + return ( + <div className="flex h-full flex-col items-center justify-center gap-8 px-8"> + <div + ref={cardRef} + className="flex w-full max-w-sm flex-col items-center gap-3 rounded-3xl bg-card p-8 text-center" + > + {character && ( + <p className="text-sm text-muted-foreground"> + {character.name} · 要去{character.destination} + </p> + )} + <p className="text-sm tracking-wide text-muted-foreground"> + {title.en} + </p> + <p className="text-4xl font-medium">{title.zh}</p> + <p className="max-w-xs text-sm leading-relaxed text-muted-foreground"> + {title.subtitle} + </p> + </div> + + <div className="flex flex-col items-center gap-3"> + {result ? ( + <QrCode data={result} size={200} /> + ) : ( + <div className="flex size-[200px] items-center justify-center rounded-xl border border-border text-sm text-muted-foreground"> + {status === 'error' ? '上傳失敗' : '產生中…'} + </div> + )} + <p className="text-xs text-muted-foreground"> + 掃描 QR Code 帶走你的設計 + </p> + </div> + </div> + ); +} diff --git a/src/components/flow/riding-step.tsx b/src/components/flow/riding-step.tsx new file mode 100644 index 0000000..95221b7 --- /dev/null +++ b/src/components/flow/riding-step.tsx @@ -0,0 +1,23 @@ +'use client'; + +import { useEffect } from 'react'; +import { STEP } from '@/lib/flow'; +import { useFlowStore } from '@/stores/flow-store'; +import { useSettingsStore } from '@/stores/settings-store'; + +export function RidingStep() { + const setStep = useFlowStore((s) => s.setStep); + const ridingSeconds = useSettingsStore((s) => s.ridingSeconds); + + useEffect(() => { + const timer = setTimeout(() => setStep(STEP.RESULT), ridingSeconds * 1000); + return () => clearTimeout(timer); + }, [setStep, ridingSeconds]); + + return ( + <div className="flex h-full flex-col items-center justify-center gap-8"> + <div className="text-5xl motion-safe:animate-bounce">🚲</div> + <p className="text-2xl font-medium">騎乘中...</p> + </div> + ); +} diff --git a/src/components/flow/settings-panel.tsx b/src/components/flow/settings-panel.tsx new file mode 100644 index 0000000..2c40043 --- /dev/null +++ b/src/components/flow/settings-panel.tsx @@ -0,0 +1,85 @@ +'use client'; + +import { useSettingsStore } from '@/stores/settings-store'; + +interface Props { + onClose: () => void; +} + +export function SettingsPanel({ onClose }: Props) { + const s = useSettingsStore(); + + return ( + <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-6"> + <div className="flex w-full max-w-md flex-col gap-4 rounded-2xl bg-card p-6"> + <h2 className="text-lg font-medium">設定</h2> + + <label className="flex flex-col gap-1 text-sm"> + 站台 ID + <input + className="rounded-lg border border-border bg-background px-3 py-2" + value={s.id} + onChange={(e) => s.setId(e.target.value)} + /> + </label> + + <label className="flex flex-col gap-1 text-sm"> + MQTT Broker URL + <input + className="rounded-lg border border-border bg-background px-3 py-2" + placeholder="wss://192.168.1.50/mqtt" + value={s.brokerUrl ?? ''} + onChange={(e) => s.setBrokerUrl(e.target.value || null)} + /> + </label> + + <div className="grid grid-cols-3 gap-3"> + <label className="flex flex-col gap-1 text-sm"> + 騎乘秒數 + <input + type="number" + className="rounded-lg border border-border bg-background px-3 py-2" + value={s.ridingSeconds} + onChange={(e) => s.setRidingSeconds(Number(e.target.value))} + /> + </label> + <label className="flex flex-col gap-1 text-sm"> + 成果秒數 + <input + type="number" + className="rounded-lg border border-border bg-background px-3 py-2" + value={s.resultSeconds} + onChange={(e) => s.setResultSeconds(Number(e.target.value))} + /> + </label> + <label className="flex flex-col gap-1 text-sm"> + 閒置秒數 + <input + type="number" + className="rounded-lg border border-border bg-background px-3 py-2" + value={s.idleSeconds} + onChange={(e) => s.setIdleSeconds(Number(e.target.value))} + /> + </label> + </div> + + <label className="flex items-center gap-2 text-sm"> + <input + type="checkbox" + checked={s.devMode} + onChange={(e) => s.setDevMode(e.target.checked)} + /> + 開發模式 + </label> + + <button + type="button" + onClick={onClose} + className="mt-2 rounded-full bg-primary py-3 text-primary-foreground" + > + 關閉 + </button> + </div> + </div> + ); +} diff --git a/src/components/flow/standby-step.tsx b/src/components/flow/standby-step.tsx new file mode 100644 index 0000000..f1c327a --- /dev/null +++ b/src/components/flow/standby-step.tsx @@ -0,0 +1,34 @@ +'use client'; + +import { useSecretKnock } from '@/hooks/use-secret-knock'; +import { STEP } from '@/lib/flow'; +import { useFlowStore } from '@/stores/flow-store'; + +interface Props { + onOpenSettings: () => void; +} + +export function StandbyStep({ onOpenSettings }: Props) { + const setStep = useFlowStore((s) => s.setStep); + const knock = useSecretKnock(onOpenSettings, 5, 1500); + + return ( + <div className="flex h-full flex-col items-center justify-center gap-12 px-10 text-center"> + <button + type="button" + onClick={knock} + className="max-w-xl text-2xl leading-relaxed font-medium" + > + 在這個城市,大家都想騎自行車到各種地方,為他們設計道路吧! + </button> + + <button + type="button" + onClick={() => setStep(STEP.CHARACTER)} + className="rounded-full bg-primary px-16 py-5 text-xl font-medium text-primary-foreground transition-transform active:scale-95" + > + 開始 + </button> + </div> + ); +} diff --git a/src/hooks/use-flow-sync.ts b/src/hooks/use-flow-sync.ts new file mode 100644 index 0000000..3518f86 --- /dev/null +++ b/src/hooks/use-flow-sync.ts @@ -0,0 +1,54 @@ +'use client'; + +import { useCallback, useEffect } from 'react'; +import { serializeFlowState } from '@/lib/flow'; +import { useFlowStore } from '@/stores/flow-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { useMQTT } from './use-mqtt'; + +/** + * Publishes the current flow state to `station/{id}/state` (retained, QoS 1) on + * every change, so the state is the single source of truth that Unity / other + * clients read and recover from on reconnect. + * + * TODO: throttle/debounce publishes during high-frequency edits (width drag) + * before sending over WiFi (architecture.md §3.7). + */ +export function useFlowSync() { + const id = useSettingsStore((s) => s.id); + const brokerUrl = useSettingsStore((s) => s.brokerUrl); + + const step = useFlowStore((s) => s.step); + const characterId = useFlowStore((s) => s.characterId); + const placed = useFlowStore((s) => s.placed); + const title = useFlowStore((s) => s.title); + const result = useFlowStore((s) => s.result); + + const stateTopic = `station/${id}/state`; + + // Controller mostly publishes; subscribe to nothing for now. + const onMessage = useCallback(() => {}, []); + const { client, connectionStatus } = useMQTT({ + brokerUrl, + topics: [], + onMessage, + }); + + useEffect(() => { + if (!client?.connected) return; + const payload = serializeFlowState({ + id, + step, + characterId, + placed, + title, + result, + }); + client.publish(stateTopic, JSON.stringify(payload), { + qos: 1, + retain: true, + }); + }, [client, stateTopic, id, step, characterId, placed, title, result]); + + return { connectionStatus }; +} diff --git a/src/lib/capture.ts b/src/lib/capture.ts new file mode 100644 index 0000000..902c775 --- /dev/null +++ b/src/lib/capture.ts @@ -0,0 +1,15 @@ +import { toBlob } from 'html-to-image'; + +// Capture a DOM node to a PNG image Blob for upload. +// +// Single seam for the result flow: the rest of the pipeline (upload, QR, MQTT +// publish) only depends on this returning a Blob. +export async function captureNode(node: HTMLElement): Promise<Blob> { + const blob = await toBlob(node, { + pixelRatio: 2, + cacheBust: true, + backgroundColor: '#ffffff', + }); + if (!blob) throw new Error('capture produced no blob'); + return blob; +} diff --git a/src/lib/flow.ts b/src/lib/flow.ts new file mode 100644 index 0000000..2b060e5 --- /dev/null +++ b/src/lib/flow.ts @@ -0,0 +1,227 @@ +// Domain model for the street-design exhibit flow. Pure data + helpers so the +// UI, MQTT payload, and (future) tests share one source of truth. + +export const STEP = { + STANDBY: 0, + CHARACTER: 1, + LEARN: 2, + REMODEL: 3, + RIDING: 4, + RESULT: 5, +} as const; + +export type Step = (typeof STEP)[keyof typeof STEP]; + +export const STEP_NAMES: Record<Step, string> = { + 0: 'standby', + 1: 'character', + 2: 'learn', + 3: 'remodel', + 4: 'riding', + 5: 'result', +}; + +/** Minimum number of distinct component types before the design can be sent. */ +export const MIN_COMPONENT_TYPES = 3; + +/** Width steps allowed for road-type components. */ +export const WIDTH_MIN = 1; +export const WIDTH_MAX = 3; +export const WIDTH_DEFAULT = 2; + +// --- Characters ------------------------------------------------------------ + +export type CharacterId = 'sam' | 'peter' | 'jenny' | 'kiki' | 'lizhu'; + +export interface Character { + id: CharacterId; + name: string; + destination: string; +} + +export const CHARACTERS: Character[] = [ + { id: 'sam', name: '山姆', destination: '同學家' }, + { id: 'peter', name: '彼得', destination: '學校' }, + { id: 'jenny', name: '珍妮', destination: '市場' }, + { id: 'kiki', name: '琪琪', destination: '公司' }, + { id: 'lizhu', name: '麗珠', destination: '公園' }, +]; + +export function getCharacter(id: CharacterId | null): Character | null { + return CHARACTERS.find((c) => c.id === id) ?? null; +} + +// --- Street components ------------------------------------------------------ + +export type ComponentCategory = 'bike' | 'pedestrian' | 'green' | 'vehicle'; + +export type ComponentKey = + | 'bike-lane' + | 'bike-priority-marking' + | 'bike-signal' + | 'bike-rack' + | 'street-tree' + | 'sidewalk' + | 'car-lane' + | 'motor-lane'; + +export interface StreetComponent { + key: ComponentKey; + name: string; + category: ComponentCategory; + /** Road-type components support width + ordering adjustment. */ + isRoad: boolean; +} + +export const COMPONENTS: StreetComponent[] = [ + { key: 'bike-lane', name: '自行車專用道', category: 'bike', isRoad: true }, + { + key: 'bike-priority-marking', + name: '自行車優先標線', + category: 'bike', + isRoad: false, + }, + { key: 'bike-signal', name: '自行車號誌', category: 'bike', isRoad: false }, + { key: 'bike-rack', name: '自行車車架', category: 'bike', isRoad: false }, + { key: 'street-tree', name: '行道樹', category: 'green', isRoad: false }, + { key: 'sidewalk', name: '人行道', category: 'pedestrian', isRoad: true }, + { key: 'car-lane', name: '汽車道', category: 'vehicle', isRoad: true }, + { key: 'motor-lane', name: '機車道', category: 'vehicle', isRoad: true }, +]; + +export const TOTAL_COMPONENT_TYPES = COMPONENTS.length; + +export function getComponent(key: ComponentKey): StreetComponent { + const found = COMPONENTS.find((c) => c.key === key); + if (!found) throw new Error(`unknown component: ${key}`); + return found; +} + +export function isRoadComponent(key: ComponentKey): boolean { + return getComponent(key).isRoad; +} + +/** A component instance placed on the street (multiple of one type allowed). */ +export interface PlacedComponent { + uid: string; + key: ComponentKey; + /** Only meaningful when the component is a road type. */ + width: number; +} + +// --- Titles ----------------------------------------------------------------- + +export type TitleKey = 'cyclist' | 'walker' | 'balancer' | 'futurist'; + +export interface Title { + key: TitleKey; + en: string; + zh: string; + subtitle: string; +} + +export const TITLES: Record<TitleKey, Title> = { + cyclist: { + key: 'cyclist', + en: "THE CYCLIST'S FRIEND", + zh: '逐風者', + subtitle: '多一個騎士,就少一台車的碳排', + }, + walker: { + key: 'walker', + en: "THE WALKER'S FRIEND", + zh: '散策者', + subtitle: '走路是碳排最低的移動方式', + }, + balancer: { + key: 'balancer', + en: 'THE BALANCER', + zh: '共行者', + subtitle: '一起為減碳努力', + }, + futurist: { + key: 'futurist', + en: 'THE FUTURIST', + zh: '未來使者', + subtitle: '你的街道,是淨零城市的榜樣', + }, +}; + +export interface TitleStats { + total: number; + typeCount: number; + bikeCount: number; + pedestrianScore: number; +} + +export function titleStats(placed: PlacedComponent[]): TitleStats { + const total = placed.length; + const typeCount = new Set(placed.map((p) => p.key)).size; + const bikeCount = placed.filter( + (p) => getComponent(p.key).category === 'bike', + ).length; + // Pedestrian-friendly score = sidewalks + street trees. + const pedestrianScore = placed.filter( + (p) => p.key === 'sidewalk' || p.key === 'street-tree', + ).length; + return { total, typeCount, bikeCount, pedestrianScore }; +} + +/** + * Deterministic title decision with explicit precedence so overlapping + * conditions resolve consistently: futurist > balancer > cyclist/walker. + * + * NOTE: the brief asks for a random title for now (see `randomTitle`). This is + * kept ready for when the real rule is switched on. + */ +export function decideTitle(placed: PlacedComponent[]): Title { + const { total, typeCount, bikeCount, pedestrianScore } = titleStats(placed); + + if (total >= 6 || typeCount === TOTAL_COMPONENT_TYPES) return TITLES.futurist; + if (bikeCount >= 1 && total - bikeCount >= 1 && typeCount >= 4) + return TITLES.balancer; + return bikeCount >= pedestrianScore ? TITLES.cyclist : TITLES.walker; +} + +/** Random title — current placeholder behaviour per the brief. */ +export function randomTitle(): Title { + const keys = Object.keys(TITLES) as TitleKey[]; + return TITLES[keys[Math.floor(Math.random() * keys.length)]]; +} + +// --- MQTT payload ----------------------------------------------------------- + +export interface FlowStatePayload { + id: string; + step: Step; + stepName: string; + character: CharacterId | null; + components: { key: ComponentKey; order: number; width: number | null }[]; + title: TitleKey | null; + result: string | null; + updatedAt: number; +} + +export function serializeFlowState(input: { + id: string; + step: Step; + characterId: CharacterId | null; + placed: PlacedComponent[]; + title: TitleKey | null; + result: string | null; +}): FlowStatePayload { + return { + id: input.id, + step: input.step, + stepName: STEP_NAMES[input.step], + character: input.characterId, + components: input.placed.map((p, i) => ({ + key: p.key, + order: i + 1, + width: isRoadComponent(p.key) ? p.width : null, + })), + title: input.title, + result: input.result, + updatedAt: Date.now(), + }; +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 2819a83..9ad0df4 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,4 +1,4 @@ -import { clsx, type ClassValue } from 'clsx'; +import { type ClassValue, clsx } from 'clsx'; import { twMerge } from 'tailwind-merge'; export function cn(...inputs: ClassValue[]) { diff --git a/src/stores/flow-store.ts b/src/stores/flow-store.ts new file mode 100644 index 0000000..d51418c --- /dev/null +++ b/src/stores/flow-store.ts @@ -0,0 +1,94 @@ +import { create } from 'zustand'; +import { + type CharacterId, + type ComponentKey, + isRoadComponent, + type PlacedComponent, + STEP, + type Step, + type TitleKey, + WIDTH_DEFAULT, + WIDTH_MAX, + WIDTH_MIN, +} from '@/lib/flow'; + +// Transient per-session state. Deliberately NOT persisted: a reload or an idle +// timeout starts a fresh session at standby. +type FlowState = { + step: Step; + characterId: CharacterId | null; + placed: PlacedComponent[]; + title: TitleKey | null; + result: string | null; + + setStep: (step: Step) => void; + selectCharacter: (id: CharacterId) => void; + addComponent: (key: ComponentKey) => void; + removeComponent: (uid: string) => void; + moveComponent: (uid: string, dir: -1 | 1) => void; + setWidth: (uid: string, width: number) => void; + setTitle: (title: TitleKey | null) => void; + setResult: (result: string | null) => void; + reset: () => void; +}; + +function clampWidth(w: number): number { + return Math.max(WIDTH_MIN, Math.min(WIDTH_MAX, Math.round(w))); +} + +export const useFlowStore = create<FlowState>()((set) => ({ + step: STEP.STANDBY, + characterId: null, + placed: [], + title: null, + result: null, + + setStep: (step) => set({ step }), + + selectCharacter: (characterId) => set({ characterId }), + + addComponent: (key) => + set((state) => ({ + placed: [ + ...state.placed, + { + uid: crypto.randomUUID(), + key, + width: isRoadComponent(key) ? WIDTH_DEFAULT : WIDTH_DEFAULT, + }, + ], + })), + + removeComponent: (uid) => + set((state) => ({ placed: state.placed.filter((p) => p.uid !== uid) })), + + moveComponent: (uid, dir) => + set((state) => { + const i = state.placed.findIndex((p) => p.uid === uid); + const j = i + dir; + if (i === -1 || j < 0 || j >= state.placed.length) return state; + const placed = state.placed.slice(); + [placed[i], placed[j]] = [placed[j], placed[i]]; + return { placed }; + }), + + setWidth: (uid, width) => + set((state) => ({ + placed: state.placed.map((p) => + p.uid === uid ? { ...p, width: clampWidth(width) } : p, + ), + })), + + setTitle: (title) => set({ title }), + + setResult: (result) => set({ result }), + + reset: () => + set({ + step: STEP.STANDBY, + characterId: null, + placed: [], + title: null, + result: null, + }), +})); diff --git a/src/stores/settings-store.ts b/src/stores/settings-store.ts index 77b68e9..4e5515e 100644 --- a/src/stores/settings-store.ts +++ b/src/stores/settings-store.ts @@ -6,8 +6,21 @@ type SettingsState = { setHasHydrated: (v: boolean) => void; devMode: boolean; setDevMode: (mode: boolean) => void; + /** Station / device id, used in MQTT topics and the state payload. */ id: string; setId: (id: string) => void; + /** MQTT broker URL, e.g. wss://192.168.1.50/mqtt. Null disables MQTT. */ + brokerUrl: string | null; + setBrokerUrl: (url: string | null) => void; + /** Seconds the riding animation (step 4) plays before showing the result. */ + ridingSeconds: number; + setRidingSeconds: (s: number) => void; + /** Seconds the result (step 5) stays before returning to standby. */ + resultSeconds: number; + setResultSeconds: (s: number) => void; + /** Idle timeout (seconds) for steps 1-3 before bouncing back to standby. */ + idleSeconds: number; + setIdleSeconds: (s: number) => void; }; export const useSettingsStore = create<SettingsState>()( @@ -19,6 +32,14 @@ export const useSettingsStore = create<SettingsState>()( setDevMode: (mode) => set({ devMode: mode }), id: 'new-client', setId: (id) => set({ id }), + brokerUrl: null, + setBrokerUrl: (brokerUrl) => set({ brokerUrl }), + ridingSeconds: 5, + setRidingSeconds: (ridingSeconds) => set({ ridingSeconds }), + resultSeconds: 8, + setResultSeconds: (resultSeconds) => set({ resultSeconds }), + idleSeconds: 30, + setIdleSeconds: (idleSeconds) => set({ idleSeconds }), }), { name: 'settings-storage',