feat: add interactive street design flow and upload result

main
uc-hoba 2 weeks ago
parent 08a69db4ab
commit 260f439b23
  1. 46
      src/app/actions/upload-result.ts
  2. 5
      src/app/globals.css
  3. 4
      src/app/layout.tsx
  4. 7
      src/app/manifest.ts
  5. 125
      src/app/page.tsx
  6. 52
      src/components/flow/character-step.tsx
  7. 25
      src/components/flow/learn-step.tsx
  8. 47
      src/components/flow/qr-code.tsx
  9. 136
      src/components/flow/remodel-step.tsx
  10. 106
      src/components/flow/result-step.tsx
  11. 23
      src/components/flow/riding-step.tsx
  12. 85
      src/components/flow/settings-panel.tsx
  13. 34
      src/components/flow/standby-step.tsx
  14. 54
      src/hooks/use-flow-sync.ts
  15. 15
      src/lib/capture.ts
  16. 227
      src/lib/flow.ts
  17. 2
      src/lib/utils.ts
  18. 94
      src/stores/flow-store.ts
  19. 21
      src/stores/settings-store.ts

@ -0,0 +1,46 @@
'use server';
import { uploadBuffer } from '@/lib/ftps';
const MIME_EXT: Record<string, string> = {
'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<UploadResult> {
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',
};
}
}

@ -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;

@ -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({

@ -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: [

@ -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 <StandbyStep onOpenSettings={onOpenSettings} />;
case STEP.CHARACTER:
return <CharacterStep />;
case STEP.LEARN:
return <LearnStep />;
case STEP.REMODEL:
return <RemodelStep />;
case STEP.RIDING:
return <RidingStep />;
case STEP.RESULT:
return <ResultStep />;
}
}
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 <div className="flex h-dvh items-center justify-center" />;
}
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{' '}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{' '}
or the{' '}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{' '}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
<div className="relative flex h-dvh w-full flex-col bg-background text-foreground">
<main className="min-h-0 flex-1">
<StepView step={step} onOpenSettings={() => setSettingsOpen(true)} />
</main>
{devMode && (
<footer className="border-t border-border px-4 py-1 text-xs text-muted-foreground">
step {step} · {STEP_NAMES[step]} · mqtt {connectionStatus}
</footer>
)}
{settingsOpen && <SettingsPanel onClose={() => setSettingsOpen(false)} />}
</div>
);
}

@ -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 (
<div className="flex h-full flex-col gap-8 px-8 py-10">
<h2 className="text-center text-xl font-medium">
</h2>
<div className="grid flex-1 grid-cols-2 content-center gap-4">
{CHARACTERS.map((c) => {
const active = characterId === c.id;
return (
<button
key={c.id}
type="button"
onClick={() => selectCharacter(c.id)}
className={cn(
'flex flex-col items-center gap-2 rounded-2xl border p-6 transition-colors',
active
? 'border-primary bg-primary/5'
: 'border-border bg-card',
)}
>
<span className="text-lg font-medium">{c.name}</span>
<span className="text-sm text-muted-foreground">
{c.destination}
</span>
</button>
);
})}
</div>
<button
type="button"
disabled={!characterId}
onClick={() => setStep(STEP.LEARN)}
className="rounded-full bg-primary py-4 text-lg font-medium text-primary-foreground transition-transform active:scale-95 disabled:opacity-40"
>
</button>
</div>
);
}

@ -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 (
<div className="flex h-full flex-col items-center justify-center gap-10 px-10 text-center">
<p className="text-2xl font-medium"></p>
<p className="max-w-sm text-base text-muted-foreground">
</p>
<button
type="button"
onClick={() => setStep(STEP.REMODEL)}
className="rounded-full bg-primary px-14 py-5 text-xl font-medium text-primary-foreground transition-transform active:scale-95"
>
</button>
</div>
);
}

@ -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<HTMLDivElement>(null);
// biome-ignore lint/suspicious/noExplicitAny: qr-code-styling instance type loaded lazily
const instanceRef = useRef<any>(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 <div ref={containerRef} role="img" aria-label="QR code" />;
}

@ -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 (
<div className="flex h-full flex-col gap-5 px-6 py-8">
<section>
<h2 className="mb-3 text-sm font-medium text-muted-foreground">
</h2>
<div className="grid grid-cols-4 gap-2">
{COMPONENTS.map((c) => (
<button
key={c.key}
type="button"
onClick={() => addComponent(c.key)}
className="flex min-h-16 items-center justify-center rounded-xl border border-border bg-card p-2 text-center text-xs leading-tight transition-transform active:scale-95"
>
{c.name}
</button>
))}
</div>
</section>
<section className="flex min-h-0 flex-1 flex-col">
<h2 className="mb-3 text-sm font-medium text-muted-foreground">
{placed.length} · {typeCount}
</h2>
<div className="flex flex-1 flex-col gap-2 overflow-y-auto">
{placed.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
</p>
) : (
placed.map((p, i) => {
const meta = getComponent(p.key);
return (
<div
key={p.uid}
className="flex items-center gap-2 rounded-xl border border-border bg-card px-3 py-2"
>
<span className="w-5 text-center text-xs text-muted-foreground">
{i + 1}
</span>
<div className="flex flex-col gap-1">
<button
type="button"
aria-label="上移"
onClick={() => moveComponent(p.uid, -1)}
className="text-xs text-muted-foreground"
>
</button>
<button
type="button"
aria-label="下移"
onClick={() => moveComponent(p.uid, 1)}
className="text-xs text-muted-foreground"
>
</button>
</div>
<span className="text-sm">{meta.name}</span>
{meta.isRoad ? (
<label className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
<input
type="range"
min={WIDTH_MIN}
max={WIDTH_MAX}
step={1}
value={p.width}
onChange={(e) =>
setWidth(p.uid, Number(e.target.value))
}
className="w-24"
/>
<span className="w-3 text-center">{p.width}</span>
</label>
) : (
<span className="ml-auto text-xs text-muted-foreground">
</span>
)}
<button
type="button"
aria-label="移除"
onClick={() => removeComponent(p.uid)}
className="ml-2 text-muted-foreground"
>
</button>
</div>
);
})
)}
</div>
</section>
{ready ? (
<button
type="button"
onClick={() => setStep(STEP.RIDING)}
className="rounded-full bg-primary py-4 text-lg font-medium text-primary-foreground transition-transform active:scale-95"
>
Go
</button>
) : (
<p className="py-2 text-center text-sm text-muted-foreground">
{MIN_COMPONENT_TYPES - typeCount}
</p>
)}
</div>
);
}

@ -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<HTMLDivElement>(null);
const startedRef = useRef(false);
// TODO: swap randomTitle() for decideTitle(placed) once the rule is final.
const [title] = useState<Title>(() => 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>
);
}

@ -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>
);
}

@ -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>
);
}

@ -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>
);
}

@ -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 };
}

@ -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;
}

@ -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(),
};
}

@ -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[]) {

@ -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,
}),
}));

@ -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',

Loading…
Cancel
Save