Configure board deployment and backend proxy
Build, Push and Deploy / build (push) Failing after 1s
Build, Push and Deploy / deploy (push) Skipped

This commit is contained in:
2026-09-13 16:09:59 +08:00
commit 02f5bf14ba
91 changed files with 12401 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
node_modules
dist
coverage
screenshots
.git
.DS_Store
.env
server/node_modules
+9
View File
@@ -0,0 +1,9 @@
APP_PORT=47880
VITE_REALTIME_MODE=websocket
VITE_REALTIME_WS_URL=/ws
VITE_WORDCLOUD_API_BASE_URL=/wordcloud-api
VITE_WORDCLOUD_DEFAULT_JOB_ID=bd1f240adcb4458f857c40ac427122c6
WORDCLOUD_API_UPSTREAM=http://114.55.99.6:8000
HARBOR_REGISTRY=192.168.31.213:10081
HARBOR_PROJECT=wordcloud
IMAGE_TAG=latest
+68
View File
@@ -0,0 +1,68 @@
name: Build, Push and Deploy
on:
push:
branches: [main, master, hotfix/*]
env:
HARBOR: 192.168.31.213:10081
HARBOR_REGISTRY: 192.168.31.213:10081
HARBOR_PROJECT: wordcloud
APP_PORT: 47880
WORDCLOUD_API_UPSTREAM: http://192.168.31.213:8000
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
run: |
set -euo pipefail
GITEA_URL="${GITHUB_SERVER_URL:-http://192.168.31.213:10880}"
git init -q .
git remote add origin "$GITEA_URL/$GITHUB_REPOSITORY.git"
git fetch -q --depth 1 origin "$GITHUB_SHA"
git checkout -q "$GITHUB_SHA"
- name: Login Harbor
run: |
echo "${{ secrets.HARBOR_PASS }}" | docker login "$HARBOR" \
-u "${{ secrets.HARBOR_USER }}" --password-stdin
- name: Build Docker images
run: |
if docker compose version >/dev/null 2>&1; then
COMPOSE="docker compose"
else
COMPOSE="docker-compose"
fi
$COMPOSE build
- name: Tag and push images
run: |
set -euo pipefail
SHA="${{ github.sha }}"
for name in wordcloud-board-app wordcloud-board-realtime; do
image="$HARBOR/$HARBOR_PROJECT/$name"
docker tag "$image:latest" "$image:$SHA"
docker push "$image:latest"
docker push "$image:$SHA"
done
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: Checkout deploy config
run: |
set -euo pipefail
GITEA_URL="${GITHUB_SERVER_URL:-http://192.168.31.213:10880}"
git init -q .
git remote add origin "$GITEA_URL/$GITHUB_REPOSITORY.git"
git fetch -q --depth 1 origin "$GITHUB_SHA"
git checkout -q "$GITHUB_SHA"
- name: Deploy WordCloud Board
run: |
chmod +x ./deploy.sh
./deploy.sh wordcloud-board production "${{ github.sha }}"
+7
View File
@@ -0,0 +1,7 @@
node_modules
dist
coverage
screenshots
*.local
.DS_Store
.env
+66
View File
@@ -0,0 +1,66 @@
# Repository Guidelines
## Project Structure
- `Design/` is the current source of truth for the product experience.
- `Design/index.html` is the overview/launcher entry point.
- `Design/control.html` is the participant control surface.
- `Design/screen.html` is the full-screen word-cloud display.
- `Design/DESIGN-MANIFEST.json` maps screens, assets, required states, and responsive checks.
- `Design/DESIGN-HANDOFF.md` defines the implementation contract and must be read before adding production code.
- `Design/drawing-*.png` and future design assets live beside the HTML screens.
The app is a React + TypeScript + Vite project rooted at the repository root; `Design/` remains the confirmed visual reference.
## Development Commands
Use npm for the React app:
```bash
npm install
npm run dev
```
Run verification before claiming completion:
```bash
npm test
npm run typecheck
npm run lint
npm run build
npm run test:server
```
For production, build and run with `docker compose up -d --build`; the port is configured with `APP_PORT`. Nginx serves the SPA and forwards `/ws` to the realtime container.
For direct design-file inspection without the app:
```bash
python3 -m http.server 8000 --directory Design
```
Then open `http://localhost:8000/index.html`, `/control.html`, or `/screen.html`.
## Coding Style
- Keep pages, reusable components, data, hooks, and realtime adapters separated.
- Use two-space indentation for HTML, CSS, TypeScript, and JavaScript.
- Use CSS Modules for page/component styles and keep visual values in `src/styles/global.css`.
- Preserve existing CSS custom properties for color, typography, spacing, radius, shadow, and motion.
- Use descriptive BEM-like class names such as `art-title`, `cloud`, `dock`, and `screen-note`; do not introduce anonymous utility classes for domain-specific layout.
- Keep user-facing copy in the existing Chinese-first style and preserve exact labels unless requirements say otherwise.
- Prefer semantic elements and visible focus states for any interactive implementation.
## Testing Guidelines
Use Vitest and React Testing Library. Keep core search, duplicate-target, queue, and adapter behavior covered by tests. For visual or interaction changes, also verify all three routes in a browser, check mobile and desktop viewports, and ensure no horizontal overflow or hidden controls. The manifest's responsive viewport list is the minimum visual checklist.
## Commit & Pull Request Guidelines
Use short, imperative commit subjects, for example `Update screen word positions` or `Add control success state`.
Pull requests should include the change reason, affected screen files, browser and viewport checks performed, and screenshots or short recordings for visual changes. Link related implementation or design tickets when available.
## Agent Guidance
Before implementing production code, read `DESIGN-HANDOFF.md` and `DESIGN-MANIFEST.json`, preserve the documented visual system, and keep launcher, control, and display surfaces separate.
+89
View File
@@ -0,0 +1,89 @@
# 65d91830-ed68-4c91-99dc-b18d3b4d3d35 implementation handoff
This archive is the source of truth for turning the design into production code. Start from `index.html`, then preserve the visual system, responsive behavior, and interactions found in the exported files.
## Implementation target
- Build production UI from the exported design, not a loose reinterpretation.
- Preserve typography scale, spacing rhythm, color tokens, border radii, shadows, motion timing, and component states.
- Replace static placeholders only when the target app has real data or functional equivalents.
- Keep generated product UI free of OpenDesign chrome, preview labels, or design-process annotations.
- Treat this handoff as a visual contract: if implementation choices conflict, match the exported pixels and behavior first, then refactor internals.
## Source map
- Primary entry: `index.html`
- HTML screens detected: 3
- Stylesheets detected: 0
- Script/component files detected: 0
- Supporting assets detected: 1
## Responsive contract
Validate the implementation across this 20252026 viewport matrix:
- Mobile compact: 360×800
- Mobile standard: 390×844
- Mobile large: 430×932
- Foldable / small tablet: 600×960
- Tablet portrait: 820×1180
- Tablet landscape: 1024×768
- Laptop: 1366×768
- Desktop: 1440×900
- Wide desktop: 1920×1080
For responsive web exports, treat these as a modern breakpoint system for one adaptive web experience, not three fixed screenshots. Do not split responsive web into unrelated native app screens unless the project explicitly includes native targets. Use semantic layout thresholds, fluid `clamp()` type/spacing, and container queries where component width matters more than viewport width. Preserve any CSS media queries, container queries, fluid `clamp()` scales, and layout changes already present in the exported files.
## Design fidelity contract
- Extract reusable tokens before writing components: background, surface, foreground, muted text, border, accent, radius, shadow, spacing, type scale, and motion duration/easing.
- Map product screens, in-app modules/components, optional landing page, and optional OS widget surfaces before coding. Keep these surfaces separate in the target architecture.
- Match layout geometry: max-widths, gutters, grid columns, card proportions, sticky/fixed elements, and viewport-specific navigation.
- Preserve real copy, labels, and data shown in the export. Do not replace specific text with generic marketing filler.
- Preserve interactive affordances: hover, focus, pressed, disabled, loading, validation, copy/share, tab/accordion, modal/sheet, and keyboard states where present.
- Preserve accessibility semantics when converting: headings stay hierarchical, controls remain buttons/links/inputs, focus states stay visible.
- Do not keep prototype-only annotations, frame labels, or OpenDesign chrome in the production UI.
## CJX-ready UX contract
- Use `DESIGN-MANIFEST.json` as the machine-readable map for screens, app modules, OS widgets, landing pages, tokens, interactions, and viewport checks.
- Screen-file-first: when multiple user-facing surfaces exist, implement each HTML screen as its own route/file. Treat `index.html` as a launcher/overview when the manifest marks it that way, not as a combined final UI.
- If `landing.html`, app screens, platform screens, or OS widget files exist, preserve those boundaries in the target app instead of merging them into one page.
- A single self-contained `index.html` is acceptable only when the export truly contains one user-facing screen and its CSS/JS are structured enough to extract tokens, components, states, and behavior.
- If separate `css/` or `js/` files exist, treat them as source of truth for token/component/interactions before porting to React, Vue, SwiftUI, Compose, or another target stack.
- In-app modules/components are product UI blocks inside the app. OS widgets are home-screen/lock-screen/quick-access surfaces outside the app. Do not merge those concepts.
## Color and brand contract
- Use the exported design tokens and product/domain context as the color source of truth.
- Do not introduce warm beige / cream / peach / pink / orange-brown background washes unless they are already explicit brand/reference colors in the export.
- No obvious token stylesheet was detected; sample colors from the entry file and convert them into named tokens before coding.
## Implementation sequence for AI coding tools
1. Open `index.html` and `DESIGN-MANIFEST.json`; identify every screen file, launcher/overview file, app module, and interaction before coding.
2. If multiple HTML screens exist, map them to separate routes/surfaces first; do not merge `landing.html`, product app screens, platform screens, or OS widgets into one route.
3. Extract a token table from CSS/root styles and inline styles before building framework components.
4. Build product screens and domain-specific in-app modules from largest layout regions down to controls; avoid starting with isolated atoms that lose spatial intent.
5. Port responsive behavior across the modern viewport matrix and test each semantic breakpoint before cleanup.
6. Port interactions and states, then replace static placeholders only with real app data or functional equivalents.
7. Keep optional landing page and OS widget surfaces as separate surfaces if present.
8. Compare final screenshots against the export at 360×800, 390×844, 430×932, 820×1180, 1024×768, 1366×768, 1440×900, and 1920×1080 before declaring done.
## Entry points
- `control.html`
- `index.html`
- `screen.html`
## Styles
- None detected
## Scripts/components
- None detected
## Assets and supporting files
- `drawing-2026-09-12T12-48-14-971Z.png`
## Coding checklist for AI tools
1. Inspect `index.html` and `DESIGN-MANIFEST.json` first and identify reusable components before coding.
2. Implement each user-facing screen file as its own route/surface; keep launcher, landing, app, platform, and OS widget files separate.
3. Extract design tokens into the target stack: colors, type scale, spacing, radius, shadows, and motion.
4. Implement layout with real 20252026 responsive breakpoints, fluid type/spacing, and container-query-aware component behavior; test with no horizontal overflow.
5. Preserve interactive controls, hover/focus/pressed states, form behavior, validation, and copy actions where present.
6. Implement domain-specific in-app modules with real states; do not flatten them into generic cards.
7. Keep landing page, product screens, and OS widget/quick-access surfaces separate when present.
8. Confirm the production result visually matches the exported design before refactoring internals.
9. Reject implementation shortcuts that flatten the design into generic cards, generic gradients, placeholder stats, or framework-default typography.
10. If a detail is ambiguous, keep the exported HTML/CSS/JS behavior rather than inventing a new pattern.
+185
View File
@@ -0,0 +1,185 @@
{
"schema": "open-design.design-manifest.v1",
"title": "65d91830-ed68-4c91-99dc-b18d3b4d3d35",
"entryFile": "index.html",
"sourceFiles": {
"all": [
"control.html",
"drawing-2026-09-12T12-48-14-971Z.png",
"index.html",
"screen.html"
],
"html": [
"control.html",
"index.html",
"screen.html"
],
"css": [],
"scriptsAndComponents": [],
"assets": [
"drawing-2026-09-12T12-48-14-971Z.png"
]
},
"screens": [
{
"file": "control.html",
"role": "screen",
"implementationNote": "Preserve visual hierarchy, responsive behavior, and interactive states from this screen."
},
{
"file": "index.html",
"role": "launcher-overview",
"implementationNote": "Use this as the navigation/overview entry only; implement each linked screen file as its own route/surface."
},
{
"file": "screen.html",
"role": "product-screen",
"implementationNote": "Preserve visual hierarchy, responsive behavior, and interactive states from this screen."
}
],
"screenFilePolicy": {
"mode": "screen-file-first",
"entryFileRole": "launcher-overview",
"rules": [
"Each distinct user-facing screen or surface must be delivered and implemented as its own file/route.",
"If a landing page is present or requested, keep it in landing.html and do not merge it into the product app screen.",
"When multiple HTML screens exist, index.html is a launcher/overview only; it must not be treated as the combined final UI.",
"Keep product app screens, landing pages, platform screens, and OS widget surfaces separate in production code."
]
},
"appModules": [
"Identify domain-specific in-app modules from the exported UI; do not reduce them to generic cards.",
"For each major module, implement purpose, default/loading/empty/error/success states, and responsive behavior.",
"Keep app modules separate from OS home-screen widgets in the production component model."
],
"osWidgets": [
"If the export includes home-screen, lock-screen, Live Activity, tablet glance, or Android widget surfaces, implement them as platform quick-access surfaces outside the app UI.",
"If none are present, do not invent OS widgets unless the product requirements request them."
],
"landingPage": {
"detection": "Inspect files and screen names for a marketing/landing page surface. If present, keep it separate from product app screens.",
"requiredSections": [
"hero",
"value props",
"product proof/screenshots",
"feature proof",
"CTA"
]
},
"tokens": {
"source": [
"index.html"
],
"required": [
"background",
"surface",
"foreground",
"muted text",
"border",
"accent",
"radius",
"shadow",
"spacing",
"type scale",
"motion"
],
"note": "Extract/freeze tokens before framework implementation so coding tools do not substitute default theme colors or typography."
},
"interactions": {
"source": [
"index.html"
],
"requiredStates": [
"default",
"hover",
"focus",
"active",
"disabled",
"loading",
"empty",
"error",
"success"
],
"requiredBehaviors": [
"forms/validation where present",
"tabs/filters where present",
"dialogs/sheets/drawers where present",
"copy/generate/share actions where present",
"player or quick controls where present"
],
"note": "If the prototype is static, derive missing behavior from visible controls and document it before coding."
},
"responsiveViewports": [
{
"name": "mobile-compact",
"width": 360,
"height": 800,
"category": "mobile",
"mustAvoidHorizontalScroll": true
},
{
"name": "mobile-standard",
"width": 390,
"height": 844,
"category": "mobile",
"mustAvoidHorizontalScroll": true
},
{
"name": "mobile-large",
"width": 430,
"height": 932,
"category": "mobile",
"mustAvoidHorizontalScroll": true
},
{
"name": "foldable-small-tablet",
"width": 600,
"height": 960,
"category": "foldable-tablet",
"mustAvoidHorizontalScroll": true
},
{
"name": "tablet-portrait",
"width": 820,
"height": 1180,
"category": "tablet",
"mustAvoidHorizontalScroll": true
},
{
"name": "tablet-landscape",
"width": 1024,
"height": 768,
"category": "tablet",
"mustAvoidHorizontalScroll": true
},
{
"name": "laptop",
"width": 1366,
"height": 768,
"category": "desktop",
"mustAvoidHorizontalScroll": true
},
{
"name": "desktop",
"width": 1440,
"height": 900,
"category": "desktop",
"mustAvoidHorizontalScroll": true
},
{
"name": "wide",
"width": 1920,
"height": 1080,
"category": "wide",
"mustAvoidHorizontalScroll": true
}
],
"implementationChecklist": [
"Open entryFile first and map screens, modules, tokens, and interactions.",
"Extract tokens before writing framework components.",
"Implement app-specific modules with real states instead of generic card grids.",
"Preserve or rebuild JS interactions for meaningful UX actions.",
"Validate screenshots at desktop/tablet/mobile viewports with no horizontal overflow.",
"Keep landing pages, in-app modules, and OS widgets as separate implementation surfaces."
]
}
+60
View File
@@ -0,0 +1,60 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>遥控器 · 找到你自己</title>
<style>
:root {
--bg:#ffffff; --surface:#f6f6f3; --surface-warm:#e5e5e0;
--fg:#211922; --fg-2:#000000; --muted:#62625b; --meta:#91918c;
--border-soft:#e0e0d9; --accent:#e60023; --success:#103c25;
--font-body:"Pin Sans",-apple-system,system-ui,Arial,sans-serif;
--text-xs:12px; --text-sm:14px; --text-base:16px; --text-xl:22px; --text-2xl:28px;
--leading-body:1.4; --tracking-display:-0.02em;
--space-2:8px; --space-3:12px; --space-4:16px; --space-6:24px; --space-8:32px; --space-12:48px;
--radius-md:16px; --radius-pill:9999px;
--focus-ring:0 0 0 3px rgba(67,94,229,.35);
}
*,*::before,*::after { box-sizing:border-box; }
html,body { margin:0;padding:0;height:100%; }
body { background:var(--bg);color:var(--fg);font-family:var(--font-body);font-size:var(--text-base);line-height:var(--leading-body); }
.scroll { min-height:100dvh;display:grid;justify-items:center;padding:48px 16px 32px; }
.box { width:min(440px,100%);margin-top:6vh;display:grid;gap:16px;align-content:start; }
.eyebrow { font-size:var(--text-xs);font-weight:700;letter-spacing:.14em;color:var(--meta);margin:0; }
h1 { margin:0;font-size:var(--text-2xl);letter-spacing:var(--tracking-display); }
.lede { margin:0;color:var(--muted); }
.field { display:grid;gap:8px;margin-top:24px; }
.field label { font-size:var(--text-sm);font-weight:700; }
.fake-input { border:1px solid var(--meta);border-radius:var(--radius-md);background:var(--bg);color:var(--meta);padding:12px 15px;min-height:48px;display:flex;align-items:center; }
.main-btn { background:var(--accent);color:#000;border-radius:var(--radius-md);padding:14px 20px;font-weight:700;font-size:var(--text-sm);text-align:center; }
.foot { font-size:var(--text-xs);color:var(--meta);text-align:center;margin:0; }
.success { text-align:center;padding:32px 0;display:grid;gap:12px;justify-items:center;border-top:1px solid var(--border-soft); }
.mark { width:64px;height:64px;border-radius:var(--radius-pill);background:var(--surface-warm);color:var(--success);display:grid;place-items:center;font-size:28px;font-weight:700; }
.success h2 { margin:0;font-size:var(--text-xl); }
.success p { margin:0;color:var(--muted);font-size:var(--text-sm); }
.static-note { font-size:var(--text-xs);color:var(--meta);text-align:center; }
</style>
</head>
<body>
<main class="scroll" aria-label="遥控器静态设计">
<div class="box">
<p class="eyebrow">LIVE · 现场互动</p>
<h1>找到你自己</h1>
<p class="lede">输入名字,它会出现在大屏幕上。</p>
<div class="field">
<label>你的名字</label>
<div class="fake-input">输入你的名字</div>
</div>
<div class="main-btn">在大屏上找到我</div>
<div class="success" aria-label="成功状态示意">
<div class="mark"></div>
<h2>找到了 · 抬头看看大屏</h2>
<p>几秒后大屏会恢复完整词云 · 再找一个名字</p>
</div>
<p class="foot">请留意现场大屏</p>
<p class="static-note">静态设计示意 · 不可交互</p>
</div>
</main>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

+126
View File
@@ -0,0 +1,126 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>个人查找 · 词云</title>
<style>
@layer od-layout {
:where(.od-stack,.od-row) > :where(*) { min-width: 0; }
.od-stack { display:flex;flex-direction:column;gap:var(--od-gap,8px); }
.od-row { display:flex;align-items:center;gap:var(--od-gap,8px); }
.od-nowrap { white-space:nowrap; }
}
</style>
<style>
:root {
--bg:#ffffff; --surface:#f6f6f3; --surface-warm:#e5e5e0;
--fg:#211922; --fg-2:#000000; --muted:#62625b; --meta:#91918c;
--border:#c8c8c1; --border-soft:#e0e0d9;
--accent:#e60023;
--font-body:"Pin Sans",-apple-system,system-ui,"Segoe UI",Roboto,Arial,sans-serif;
--font-art:"Pin Sans",-apple-system,"PingFang SC","Hiragino Sans GB","Noto Sans SC","Microsoft YaHei",system-ui,sans-serif;
--text-xs:12px; --text-sm:14px; --text-base:16px; --text-lg:18px; --text-2xl:28px;
--leading-body:1.4; --tracking-display:-0.02em;
--space-2:8px; --space-3:12px; --space-4:16px; --space-6:24px;
--radius-md:16px; --radius-pill:9999px;
--elev-raised:0 4px 16px rgba(33,25,34,0.06);
}
*,*::before,*::after { box-sizing:border-box; }
html,body { margin:0;padding:0;height:100%; }
body { background:var(--bg);color:var(--fg);font-family:var(--font-body);font-size:var(--text-base);line-height:var(--leading-body); }
.page { height:100dvh;position:relative;overflow:hidden; }
.cloud { position:absolute;inset:0; }
.cloud span { position:absolute;transform:translate(-50%,-50%);white-space:nowrap;font-family:var(--font-art);color:var(--fg); }
.t1 { font-size:15px;opacity:.55;font-weight:500; }
.t2 { font-size:22px;opacity:.7;font-weight:600; }
.t3 { font-size:34px;opacity:.85;font-weight:700; }
.t4 { font-size:52px;opacity:.94;font-weight:700;letter-spacing:var(--tracking-display); }
.eye { position:absolute;left:16px;top:max(16px,env(safe-area-inset-top));font-size:var(--text-xs);letter-spacing:.14em;color:var(--meta);font-weight:700;margin:0;z-index:3; }
.art-title { position:absolute;left:50%;top:max(44px,calc(env(safe-area-inset-top) + 36px));transform:translateX(-50%);text-align:center;pointer-events:none;margin:0;max-width:min(88vw,640px);z-index:2;width:max-content; }
.art-title h1 { margin:0;font-size:clamp(22px,6vw,32px);font-weight:700;letter-spacing:var(--tracking-display);line-height:1.2;text-wrap:balance; }
.art-title p { margin:6px 0 0;font-size:var(--text-xs);letter-spacing:.2em;color:var(--meta);font-weight:700;white-space:nowrap; }
.art-title::after { content:"";display:block;width:32px;height:2px;background:var(--accent);border-radius:2px;margin:10px auto 0;opacity:.9; }
.dock-wrap { position:absolute;left:0;right:0;bottom:0;display:flex;justify-content:center;padding:0 16px calc(16px + env(safe-area-inset-bottom)); }
.dock { width:min(520px,100%);background:var(--surface);border:1px solid var(--border-soft);border-radius:var(--radius-md);box-shadow:var(--elev-raised);padding:12px;display:grid;gap:8px; }
.row { display:flex;gap:8px; }
.fake-input { flex:1;border:1px solid var(--meta);border-radius:var(--radius-md);background:var(--bg);color:var(--meta);padding:12px 15px;font-size:var(--text-base); }
.fake-btn { background:var(--accent);color:#000;border-radius:var(--radius-md);padding:12px 20px;font-weight:700;font-size:var(--text-sm);display:grid;place-items:center; }
.found { background:var(--bg);border:1px dashed var(--border);border-radius:var(--radius-md);padding:10px 12px; }
.found strong { display:block;font-size:var(--text-base); }
.found small { color:var(--muted);font-size:var(--text-sm); }
.found-row { display:flex;gap:8px;align-items:center;margin-top:8px; }
.ghost-btn { background:var(--surface-warm);color:var(--fg-2);border-radius:var(--radius-md);padding:10px 16px;font-size:var(--text-sm);font-weight:700; }
.dock-note { font-size:var(--text-xs);color:var(--meta);text-align:center;margin:0; }
</style>
</head>
<body>
<main class="page" aria-label="个人查找静态设计">
<p class="eye">LIVE ARCHIVE · 2026</p>
<div class="art-title">
<h1>2026 毕业典礼 · 全体名单</h1>
<p>CLASS OF 2026 · WORD CLOUD</p>
</div>
<div class="cloud" aria-hidden="true">
<span class="t4" style="left:50%;top:44%">王小明</span>
<span class="t3" style="left:32%;top:30%">林嘉怡</span>
<span class="t3" style="left:68%;top:30%">Sophia</span>
<span class="t3" style="left:24%;top:58%">李欣</span>
<span class="t3" style="left:76%;top:60%">Kevin</span>
<span class="t3" style="left:50%;top:66%">陈晨</span>
<span class="t2" style="left:38%;top:52%">Alex</span>
<span class="t2" style="left:62%;top:52%">Lily</span>
<span class="t2" style="left:44%;top:24%">张雨</span>
<span class="t2" style="left:58%;top:76%">Daniel</span>
<span class="t2" style="left:18%;top:40%">赵天宇</span>
<span class="t2" style="left:82%;top:42%">Emma</span>
<span class="t2" style="left:30%;top:76%">苏晚晴</span>
<span class="t2" style="left:70%;top:78%">周子墨</span>
<span class="t2" style="left:50%;top:14%">Olivia</span>
<span class="t2" style="left:50%;top:88%">沈知遥</span>
<span class="t1" style="left:14%;top:66%">王梓涵</span>
<span class="t1" style="left:86%;top:66%">李雨桐</span>
<span class="t1" style="left:22%;top:22%">张浩然</span>
<span class="t1" style="left:78%;top:20%">刘亦菲</span>
<span class="t1" style="left:12%;top:52%">陈子豪</span>
<span class="t1" style="left:88%;top:52%">杨晨曦</span>
<span class="t1" style="left:36%;top:84%">黄俊杰</span>
<span class="t1" style="left:64%;top:84%">吴思远</span>
<span class="t1" style="left:26%;top:44%">徐若瑄</span>
<span class="t1" style="left:74%;top:46%">孙浩宇</span>
<span class="t1" style="left:42%;top:38%">马晓彤</span>
<span class="t1" style="left:58%;top:38%">朱子睿</span>
<span class="t1" style="left:34%;top:64%">胡文轩</span>
<span class="t1" style="left:66%;top:64%">郭美玲</span>
<span class="t1" style="left:46%;top:58%">何佳怡</span>
<span class="t1" style="left:55%;top:59%">罗子涵</span>
<span class="t1" style="left:20%;top:82%">James</span>
<span class="t1" style="left:80%;top:82%">Mary</span>
<span class="t1" style="left:16%;top:30%">John</span>
<span class="t1" style="left:84%;top:30%">Grace</span>
<span class="t1" style="left:40%;top:10%">Noah</span>
<span class="t1" style="left:60%;top:10%">Mia</span>
<span class="t1" style="left:50%;top:94%"></span>
<span class="t2" style="left:68%;top:14%">Alexandrina</span>
<span class="t2" style="left:30%;top:90%">欧阳雨桐溪</span>
</div>
<div class="dock-wrap">
<div class="dock">
<div class="row">
<div class="fake-input">输入你的名字</div>
<div class="fake-btn">查找</div>
</div>
<div class="found">
<strong>找到你了 · 王小明</strong>
<small>你在这里 · 静态示意,不可点击</small>
<div class="found-row">
<span class="ghost-btn">查看全图</span>
<small>找到 2 处 · 1 / 2</small>
</div>
</div>
<p class="dock-note">静态设计示意 · 不可交互</p>
</div>
</div>
</main>
</body>
</html>
+98
View File
@@ -0,0 +1,98 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>大屏展示 · 词云</title>
<style>
:root {
--bg:#ffffff; --surface:#f6f6f3; --surface-warm:#e5e5e0;
--fg:#211922; --fg-2:#000000; --muted:#62625b; --meta:#91918c;
--border-soft:#e0e0d9; --accent:#e60023;
--font-body:"Pin Sans",-apple-system,system-ui,Arial,sans-serif;
--font-art:"Pin Sans",-apple-system,"PingFang SC","Noto Sans SC","Microsoft YaHei",system-ui,sans-serif;
--text-xs:12px; --text-sm:14px; --text-lg:18px;
--radius-md:16px; --radius-sm:12px;
--elev-raised:0 4px 16px rgba(33,25,34,0.06);
}
*,*::before,*::after { box-sizing:border-box; }
html,body { margin:0;padding:0;height:100%; }
body { background:var(--bg);color:var(--fg);font-family:var(--font-body); }
.page { height:100dvh;position:relative;overflow:hidden; }
.cloud { position:absolute;inset:0; }
.cloud span { position:absolute;transform:translate(-50%,-50%);white-space:nowrap;font-family:var(--font-art);color:var(--fg); }
.t1 { font-size:18px;opacity:.55;font-weight:500; }
.t2 { font-size:28px;opacity:.7;font-weight:600; }
.t3 { font-size:44px;opacity:.85;font-weight:700; }
.t4 { font-size:72px;opacity:.94;font-weight:700; }
.eye { position:absolute;left:48px;top:48px;font-size:var(--text-xs);letter-spacing:.14em;color:var(--meta);font-weight:700;margin:0;z-index:3; }
.art-title { position:absolute;left:50%;top:48px;transform:translateX(-50%);text-align:center;pointer-events:none;margin:0;max-width:min(80vw,900px);z-index:2;width:max-content; }
.art-title h1 { margin:0;font-size:clamp(38px,4.2vw,62px);font-weight:700;letter-spacing:-0.02em;line-height:1.1;text-wrap:balance; }
.art-title p { margin:10px 0 0;font-size:var(--text-sm);letter-spacing:.24em;color:var(--meta);font-weight:700;white-space:nowrap; }
.art-title::after { content:"";display:block;width:48px;height:3px;background:var(--accent);border-radius:3px;margin:14px auto 0;opacity:.9; }
.cloud { position:absolute;inset:0;z-index:1; }
.caption { position:absolute;left:48px;bottom:48px;margin:0;font-size:var(--text-sm);letter-spacing:.12em;color:var(--meta); }
.qr { position:absolute;right:48px;bottom:48px;background:var(--surface);border:1px solid var(--border-soft);border-radius:var(--radius-md);box-shadow:var(--elev-raised);padding:16px;display:flex;gap:16px;align-items:center;max-width:360px; }
.qr-code { width:168px;height:168px;flex:none;background:#fff;border-radius:var(--radius-sm);display:grid;grid-template-columns:repeat(9,1fr);grid-template-rows:repeat(9,1fr);gap:1px;padding:10px;border:1px solid var(--border-soft); }
.qr-code i { background:#fff;display:block; }
.qr-code i.b { background:var(--fg); }
.qr h2 { margin:0 0 4px;font-size:var(--text-lg); }
.qr p { margin:0;font-size:var(--text-sm);color:var(--muted); }
.screen-note { position:absolute;left:48px;top:76px;font-size:var(--text-xs);color:var(--meta); }
@media (max-width:760px) {
.eye { left:16px;top:16px; }
.caption { display:none; }
.screen-note { left:16px;top:40px; }
.qr { right:16px;left:16px;bottom:16px;max-width:none; }
.qr-code { width:120px;height:120px; }
}
</style>
</head>
<body>
<main class="page" aria-label="大屏展示静态设计">
<p class="eye">LIVE ARCHIVE · 2026</p>
<div class="art-title">
<h1>2026 毕业典礼 · 全体名单</h1>
<p>CLASS OF 2026 · WORD CLOUD</p>
</div>
<p class="screen-note">静态设计示意 · 不可交互</p>
<div class="cloud" aria-hidden="true">
<span class="t4" style="left:50%;top:44%">王小明</span>
<span class="t3" style="left:32%;top:28%">林嘉怡</span>
<span class="t3" style="left:68%;top:28%">Sophia</span>
<span class="t3" style="left:24%;top:56%">李欣</span>
<span class="t3" style="left:76%;top:58%">Kevin</span>
<span class="t3" style="left:50%;top:66%">陈晨</span>
<span class="t2" style="left:38%;top:50%">Alex</span>
<span class="t2" style="left:62%;top:50%">Lily</span>
<span class="t2" style="left:44%;top:20%">张雨</span>
<span class="t2" style="left:58%;top:78%">Daniel</span>
<span class="t2" style="left:16%;top:40%">赵天宇</span>
<span class="t2" style="left:84%;top:42%">Emma</span>
<span class="t2" style="left:30%;top:78%">苏晚晴</span>
<span class="t2" style="left:70%;top:80%">周子墨</span>
<span class="t1" style="left:14%;top:64%">王梓涵</span>
<span class="t1" style="left:86%;top:64%">李雨桐</span>
<span class="t1" style="left:22%;top:20%">张浩然</span>
<span class="t1" style="left:78%;top:18%">刘亦菲</span>
<span class="t1" style="left:36%;top:84%">黄俊杰</span>
<span class="t1" style="left:64%;top:84%">吴思远</span>
<span class="t1" style="left:42%;top:36%">马晓彤</span>
<span class="t1" style="left:58%;top:36%">朱子睿</span>
<span class="t1" style="left:20%;top:82%">James</span>
<span class="t1" style="left:80%;top:82%">Mary</span>
<span class="t1" style="left:50%;top:12%">Olivia</span>
<span class="t1" style="left:50%;top:90%">沈知遥</span>
</div>
<p class="caption">THIS WALL IS MADE OF EVERYONE HERE</p>
<aside class="qr" aria-label="扫码互动静态示意">
<div class="qr-code" aria-hidden="true"><i class="b"></i><i class="b"></i><i class="b"></i><i></i><i class="b"></i><i></i><i class="b"></i><i class="b"></i><i class="b"></i><i class="b"></i><i></i><i></i><i></i><i></i><i class="b"></i><i></i><i></i><i class="b"></i><i class="b"></i><i></i><i class="b"></i><i></i><i></i><i></i><i class="b"></i><i></i><i class="b"></i><i></i><i></i><i></i><i class="b"></i><i></i><i class="b"></i><i></i><i></i><i></i><i></i><i class="b"></i><i class="b"></i><i></i><i></i><i class="b"></i><i></i><i class="b"></i><i></i><i></i><i class="b"></i><i></i><i></i><i class="b"></i><i></i><i class="b"></i><i></i><i class="b"></i><i class="b"></i><i class="b"></i><i></i><i></i><i class="b"></i><i></i><i></i><i class="b"></i><i></i><i class="b"></i><i class="b"></i><i></i><i class="b"></i><i></i><i></i><i></i><i></i><i class="b"></i><i></i><i></i><i class="b"></i><i></i><i class="b"></i><i class="b"></i><i class="b"></i><i></i><i></i><i class="b"></i></div>
<div>
<h2>找到你的名字</h2>
<p>扫码输入名字</p>
<p>静态占位 · 不可扫码</p>
</div>
</aside>
</main>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
ARG VITE_REALTIME_MODE=websocket
ARG VITE_REALTIME_WS_URL=/ws
ARG VITE_WORDCLOUD_API_BASE_URL=/wordcloud-api
ARG VITE_WORDCLOUD_DEFAULT_JOB_ID=bd1f240adcb4458f857c40ac427122c6
ENV VITE_REALTIME_MODE=$VITE_REALTIME_MODE
ENV VITE_REALTIME_WS_URL=$VITE_REALTIME_WS_URL
ENV VITE_WORDCLOUD_API_BASE_URL=$VITE_WORDCLOUD_API_BASE_URL
ENV VITE_WORDCLOUD_DEFAULT_JOB_ID=$VITE_WORDCLOUD_DEFAULT_JOB_ID
RUN npm run build
FROM nginx:1.27-alpine
ENV WORDCLOUD_API_UPSTREAM=http://192.168.31.213:8000
COPY deploy/nginx.conf /etc/nginx/templates/default.conf.template
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
HEALTHCHECK --interval=15s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://127.0.0.1/healthz | grep -q '^ok$'
+76
View File
@@ -0,0 +1,76 @@
# wc-board
词云现场互动 Web 应用,包含个人查找、大屏展示和遥控器三个页面。`demo` 可用于离线验收;其他路由会在运行时加载 WordCloud 任务的位置数据与 SVG。
## Quick Start
```bash
npm install
npm run dev
```
打开以下路由验证离线完整流程:
```text
/cloud/demo
/screen/demo
/control/demo
```
在同一浏览器的两个 tab 中分别打开大屏与遥控器后,遥控器提交名字可以让大屏聚焦;多个连续提交会排队展示。
## Commands
```bash
npm run dev
npm run build
npm run preview
npm test
npm run typecheck
npm run lint
```
## Structure
- `src/pages/`Personal Search、Public Display、Remote Controller。
- `src/components/`:词云、搜索 dock、QR panel、状态组件。
- `src/data/clouds/`:离线 `demo` 词云数据。
- `src/lib/wordcloudApi.ts`WordCloud API 适配器;运行时读取位置数据与 SVG。
- `src/hooks/useCloud.ts`:为三个页面统一加载本地 demo 或远程任务词云。
- `src/hooks/`focus lifecycle、queue 和 realtime integration。
- `src/realtime/`BroadcastChannel adapter;后续 WebSocket/Supabase 等实现替换这一层。
- `src/styles/global.css`:从既有设计提取的全局 tokens。
- `Design/`:已确认的视觉基准和设计 handoff。
本地开发默认使用同一浏览器内的 BroadcastChannel;生产构建默认使用同源 WebSocket adapter,适合多设备访问。当前 realtime 服务是内存转发,暂不保存名单或事件。
## Production Deployment
```bash
cp .env.example .env
docker compose up -d --build
```
默认端口由 `.env``APP_PORT` 控制,例如 `APP_PORT=47880` 时通过下面地址访问:
```text
http://<server-host>:47880/cloud/demo
http://<server-host>:47880/screen/demo
http://<server-host>:47880/control/demo
```
生产构建使用 WebSocket adapter,浏览器连接同源 `/ws`Nginx 会将该路径转发到 `realtime` 容器。因为二维码按当前浏览器的 origin 生成,通过 A 域名打开大屏时,扫码会进入 A 域名的 Controller,而不会跳去 B 域名。
如果 A 和 B 是不同域名并都指向同一台服务器,可以在外层网关或 DNS 上都转发到 Compose 暴露的端口;前端与二维码会保留访问者实际使用的域名。
`demo` 外,路由参数即 WordCloud `jobId`。例如:
```text
/cloud/bd1f240adcb4458f857c40ac427122c6
/screen/bd1f240adcb4458f857c40ac427122c6
/control/bd1f240adcb4458f857c40ac427122c6
```
`/cloud/imported``/screen/imported``/control/imported` 是由 `VITE_WORDCLOUD_DEFAULT_JOB_ID` 配置的默认任务别名。应用默认从同源 `/wordcloud-api` 读取位置与 SVG,Nginx 再将其转发到 `WORDCLOUD_API_UPSTREAM`,默认值为 `http://192.168.31.213:8000`。这样浏览器不需要跨域读取 SVG,且多域名部署时仍保留访问者实际使用的域名。
本地 `npm run dev` 也会将 `/wordcloud-api` 转发到同一个后端。只有在已具备可靠 CORS 的场景,才需要把 `VITE_WORDCLOUD_API_BASE_URL` 改为完整的远端 URL。
Executable
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
APP_NAME="${1:-wordcloud-board}"
TARGET_ENV="${2:-production}"
IMAGE_TAG="${3:-latest}"
cd "$(dirname "$0")"
if docker compose version >/dev/null 2>&1; then
COMPOSE="docker compose"
else
COMPOSE="docker-compose"
fi
export COMPOSE_PROJECT_NAME="${APP_NAME}"
export HARBOR_REGISTRY="${HARBOR_REGISTRY:-192.168.31.213:10081}"
export HARBOR_PROJECT="${HARBOR_PROJECT:-wordcloud}"
export APP_PORT="${APP_PORT:-47880}"
export WORDCLOUD_API_UPSTREAM="${WORDCLOUD_API_UPSTREAM:-http://192.168.31.213:8000}"
export IMAGE_TAG
echo "[deploy] project=${APP_NAME} env=${TARGET_ENV} image_tag=${IMAGE_TAG} port=${APP_PORT}"
"$COMPOSE" pull
"$COMPOSE" up -d --remove-orphans
"$COMPOSE" ps
+57
View File
@@ -0,0 +1,57 @@
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
location = /healthz {
access_log off;
default_type text/plain;
return 200 'ok\n';
}
location /ws {
proxy_pass http://realtime:8787;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_read_timeout 1d;
proxy_send_timeout 1d;
}
location ^~ /wordcloud-api/ {
proxy_pass ${WORDCLOUD_API_UPSTREAM}/;
proxy_http_version 1.1;
proxy_set_header Host $proxy_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
}
location ^~ /assets/ {
try_files $uri =404;
add_header Cache-Control "public, max-age=31536000, immutable";
}
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-store, must-revalidate";
}
}
+30
View File
@@ -0,0 +1,30 @@
services:
app:
image: ${HARBOR_REGISTRY:-192.168.31.213:10081}/wordcloud/wordcloud-board-app:${IMAGE_TAG:-latest}
build:
context: .
dockerfile: Dockerfile.frontend
args:
VITE_REALTIME_MODE: ${VITE_REALTIME_MODE:-websocket}
VITE_REALTIME_WS_URL: ${VITE_REALTIME_WS_URL:-/ws}
VITE_WORDCLOUD_API_BASE_URL: ${VITE_WORDCLOUD_API_BASE_URL:-/wordcloud-api}
VITE_WORDCLOUD_DEFAULT_JOB_ID: ${VITE_WORDCLOUD_DEFAULT_JOB_ID:-bd1f240adcb4458f857c40ac427122c6}
ports:
- "${APP_PORT:-47880}:80"
depends_on:
realtime:
condition: service_healthy
environment:
WORDCLOUD_API_UPSTREAM: ${WORDCLOUD_API_UPSTREAM:-http://192.168.31.213:8000}
restart: unless-stopped
realtime:
image: ${HARBOR_REGISTRY:-192.168.31.213:10081}/wordcloud/wordcloud-board-realtime:${IMAGE_TAG:-latest}
build:
context: ./server
dockerfile: Dockerfile
environment:
REALTIME_PORT: 8787
expose:
- "8787"
restart: unless-stopped
+26
View File
@@ -0,0 +1,26 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist', 'node_modules'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'no-console': 'warn',
'@typescript-eslint/no-explicit-any': 'error',
},
},
);
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>找到你自己 · 词云</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+5339
View File
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
{
"name": "wc-board",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"dev:realtime": "npm --prefix server run dev",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"lint": "eslint .",
"test:server": "npm --prefix server test"
},
"dependencies": {
"lucide-react": "^1.45.0",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.18.3"
},
"devDependencies": {
"@eslint/js": "^8.57.1",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
"@types/node": "^22.10.2",
"@types/qrcode": "^1.5.5",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^8.57.1",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.16",
"globals": "^15.14.0",
"jsdom": "^24.1.3",
"typescript": "~5.6.3",
"typescript-eslint": "^7.18.0",
"vite": "^5.4.19",
"vitest": "^2.1.8"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.7 MiB

+2
View File
@@ -0,0 +1,2 @@
node_modules
.DS_Store
+13
View File
@@ -0,0 +1,13 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY realtime-server.mjs ./
USER node
EXPOSE 8787
HEALTHCHECK --interval=15s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://127.0.0.1:8787/healthz | grep -q '"status":"ok"'
CMD ["node", "realtime-server.mjs"]
+36
View File
@@ -0,0 +1,36 @@
{
"name": "wc-board-realtime",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wc-board-realtime",
"version": "0.1.0",
"dependencies": {
"ws": "^8.18.3"
}
},
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "wc-board-realtime",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "node realtime-server.mjs",
"start": "node realtime-server.mjs",
"test": "node --test"
},
"dependencies": {
"ws": "^8.18.3"
}
}
+91
View File
@@ -0,0 +1,91 @@
import { createServer } from 'node:http';
import { pathToFileURL } from 'node:url';
import { WebSocketServer } from 'ws';
function isFocusEvent(value) {
return (
typeof value === 'object' &&
value !== null &&
value.type === 'FOCUS_NAME' &&
typeof value.cloudId === 'string' &&
typeof value.name === 'string' &&
typeof value.messageId === 'string' &&
typeof value.sentAt === 'number' &&
(value.targetIndex === undefined ||
(typeof value.targetIndex === 'number' &&
Number.isInteger(value.targetIndex) &&
value.targetIndex >= 0))
);
}
export function createRealtimeServer(port = Number(process.env.REALTIME_PORT || 8787)) {
const server = createServer((request, response) => {
if (request.url === '/healthz') {
response.writeHead(200, { 'content-type': 'application/json' });
response.end(JSON.stringify({ status: 'ok' }));
return;
}
response.writeHead(404, { 'content-type': 'text/plain' });
response.end('Not found');
});
const realtime = new WebSocketServer({
server,
maxPayload: 16 * 1024,
});
const heartbeat = setInterval(() => {
for (const socket of realtime.clients) {
if (socket.isAlive === false) {
socket.terminate();
continue;
}
socket.isAlive = false;
socket.ping();
}
}, 30_000);
heartbeat.unref();
realtime.on('connection', (socket) => {
socket.isAlive = true;
socket.on('pong', () => {
socket.isAlive = true;
});
socket.on('message', (data) => {
let value;
try {
value = JSON.parse(data.toString());
} catch {
return;
}
if (!isFocusEvent(value)) {
return;
}
const message = JSON.stringify(value);
for (const client of realtime.clients) {
if (client !== socket && client.readyState === client.OPEN) {
client.send(message);
}
}
});
});
realtime.on('close', () => {
clearInterval(heartbeat);
});
return server;
}
if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
const port = Number(process.env.REALTIME_PORT || 8787);
createRealtimeServer(port).listen(port, () => {
console.log(`Realtime WebSocket server listening on port ${port}`);
});
}
+45
View File
@@ -0,0 +1,45 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createRealtimeServer } from './realtime-server.mjs';
import WebSocket from 'ws';
test('broadcasts focus events to other connected clients', async () => {
const server = createRealtimeServer(0);
await new Promise((resolve) => server.listen(resolve));
const { port } = server.address();
try {
const screen = new WebSocket(`ws://127.0.0.1:${port}`);
const controller = new WebSocket(`ws://127.0.0.1:${port}`);
await Promise.all([
new Promise((resolve) => screen.once('open', resolve)),
new Promise((resolve) => controller.once('open', resolve)),
]);
const received = new Promise((resolve) => {
screen.once('message', (data) => resolve(JSON.parse(data)));
});
controller.send(JSON.stringify({
type: 'FOCUS_NAME',
cloudId: 'demo',
name: 'Sophia',
messageId: 'focus-1',
sentAt: 1,
}));
assert.deepEqual(await received, {
type: 'FOCUS_NAME',
cloudId: 'demo',
name: 'Sophia',
messageId: 'focus-1',
sentAt: 1,
});
screen.close();
controller.close();
} finally {
await new Promise((resolve) => server.close(resolve));
}
});
+5
View File
@@ -0,0 +1,5 @@
import { AppRoutes } from './AppRoutes';
export default function App() {
return <AppRoutes />;
}
+217
View File
@@ -0,0 +1,217 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import { afterEach, vi } from 'vitest';
import { AppRoutes } from './AppRoutes';
const remoteJobId = 'bd1f240adcb4458f857c40ac427122c6';
const remoteLocationResult = {
job_id: remoteJobId,
query: '',
mode: 'exact',
total: 2,
canvas_width: 4000,
canvas_height: 3563,
matches: [
{
id: 1,
name: '王小明',
x: 100,
y: 100,
font_size: 80,
color: '#000000',
orientation: 'horizontal',
box_x: 100,
box_y: 100,
box_width: 240,
box_height: 80,
},
{
id: 2,
name: ' 李欣',
x: 400,
y: 400,
font_size: 48,
color: '#000000',
orientation: 'vertical',
box_x: 400,
box_y: 400,
box_width: 48,
box_height: 96,
},
],
};
const remoteSvg = `<svg viewBox="0 0 4000 3563" xmlns="http://www.w3.org/2000/svg">${
'<path d="M0 0"/>'.repeat(5)
}</svg>`;
function mockRemoteCloudApi() {
return vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith(`/api/jobs/${remoteJobId}`)) {
return new Response('job not found', { status: 404 });
}
if (url.includes(`/api/jobs/${remoteJobId}/locations`)) {
return new Response(JSON.stringify(remoteLocationResult), { status: 200 });
}
if (url.endsWith(`/api/jobs/${remoteJobId}/files/svg`)) {
return new Response(remoteSvg, { status: 200 });
}
return new Response('not found', { status: 404 });
});
}
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe('application routes', () => {
it('renders the personal search word cloud and input', () => {
render(
<MemoryRouter initialEntries={['/cloud/demo']}>
<AppRoutes />
</MemoryRouter>,
);
expect(screen.getAllByText('王小明')).toHaveLength(2);
expect(screen.getByPlaceholderText('输入你的名字')).toBeInTheDocument();
});
it('enables the localized title layer on both word cloud views', () => {
const personal = render(
<MemoryRouter initialEntries={['/cloud/demo']}>
<AppRoutes />
</MemoryRouter>,
);
expect(personal.container.querySelector('[class*="title"]'))
.toHaveAttribute('data-layered', 'true');
personal.unmount();
render(
<MemoryRouter initialEntries={['/screen/demo']}>
<AppRoutes />
</MemoryRouter>,
);
expect(document.querySelector('[class*="title"]'))
.toHaveAttribute('data-layered', 'true');
});
it('renders the public display with a real QR panel', async () => {
render(
<MemoryRouter initialEntries={['/screen/demo']}>
<AppRoutes />
</MemoryRouter>,
);
expect(screen.getByRole('heading', { name: '2026 毕业典礼 · 全体名单' })).toBeInTheDocument();
expect(await screen.findByRole('img', { name: '扫码进入遥控器' })).toBeInTheDocument();
expect(screen.getByText('找到你的名字')).toBeInTheDocument();
});
it('provides a fullscreen control on the public display', () => {
render(
<MemoryRouter initialEntries={['/screen/demo']}>
<AppRoutes />
</MemoryRouter>,
);
expect(screen.getByRole('button', { name: '进入全屏' })).toBeInTheDocument();
});
it('loads a remote job from locations when the job-status route omits disk-backed jobs', async () => {
const observe = vi.fn();
class ResizeObserverMock {
constructor() {}
observe = observe;
disconnect = vi.fn();
}
vi.stubGlobal('ResizeObserver', ResizeObserverMock);
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
width: 1200,
height: 800,
top: 0,
right: 1200,
bottom: 800,
left: 0,
x: 0,
y: 0,
toJSON: () => ({}),
});
const fetchMock = mockRemoteCloudApi();
vi.stubGlobal('fetch', fetchMock);
const user = userEvent.setup();
const personal = render(
<MemoryRouter initialEntries={[`/cloud/${remoteJobId}`]}>
<AppRoutes />
</MemoryRouter>,
);
await waitFor(() => {
expect(personal.container.querySelectorAll('g[data-word-id]')).toHaveLength(2);
});
expect(observe).toHaveBeenCalledWith(expect.any(HTMLDivElement));
expect(fetchMock).toHaveBeenCalledWith(
`/wordcloud-api/api/jobs/${remoteJobId}/locations?name=`,
);
expect(fetchMock).not.toHaveBeenCalledWith(`/wordcloud-api/api/jobs/${remoteJobId}`);
await user.type(screen.getByPlaceholderText('输入你的名字'), '王小明');
await user.click(screen.getByRole('button', { name: '查找' }));
await waitFor(() => {
expect(personal.container.querySelector('[data-phase="focusing"]')?.getAttribute('style'))
.not.toContain('translate3d(0px, 0px, 0px)');
});
personal.unmount();
const screenView = render(
<MemoryRouter initialEntries={[`/screen/${remoteJobId}`]}>
<AppRoutes />
</MemoryRouter>,
);
await waitFor(() => {
expect(screenView.container.querySelectorAll('g[data-word-id]')).toHaveLength(2);
});
});
it('renders the remote controller form', () => {
render(
<MemoryRouter initialEntries={['/control/demo']}>
<AppRoutes />
</MemoryRouter>,
);
expect(screen.getByRole('heading', { name: '找到你自己' })).toBeInTheDocument();
expect(screen.getByLabelText('你的名字')).toBeInTheDocument();
});
it('lets the controller move between duplicate name matches', async () => {
const user = userEvent.setup();
render(
<MemoryRouter initialEntries={['/control/demo']}>
<AppRoutes />
</MemoryRouter>,
);
await user.type(screen.getByLabelText('你的名字'), 'Alex');
await user.click(screen.getByRole('button', { name: '在大屏上找到我' }));
expect(screen.getByText('找到 2 处 · 1 / 2')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: '下一个名字' }));
expect(screen.getByText('找到 2 处 · 2 / 2')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: '上一个名字' }));
expect(screen.getByText('找到 2 处 · 1 / 2')).toBeInTheDocument();
});
});
+34
View File
@@ -0,0 +1,34 @@
import { Navigate, Route, Routes, useParams } from 'react-router-dom';
import { PersonalSearchPage } from './pages/PersonalSearchPage';
import { PublicDisplayPage } from './pages/PublicDisplayPage';
import { RemoteControllerPage } from './pages/RemoteControllerPage';
export function AppRoutes() {
return (
<Routes>
<Route path="/" element={<Navigate to="/cloud/imported" replace />} />
<Route path="/cloud/:cloudId" element={<PersonalRoute />} />
<Route path="/screen/:cloudId" element={<PublicRoute />} />
<Route path="/control/:cloudId" element={<ControllerRoute />} />
<Route path="*" element={<Navigate to="/cloud/imported" replace />} />
</Routes>
);
}
function PersonalRoute() {
const { cloudId = '' } = useParams();
return <PersonalSearchPage cloudId={cloudId} />;
}
function PublicRoute() {
const { cloudId = '' } = useParams();
return <PublicDisplayPage cloudId={cloudId} />;
}
function ControllerRoute() {
const { cloudId = '' } = useParams();
return <RemoteControllerPage cloudId={cloudId} />;
}
+99
View File
@@ -0,0 +1,99 @@
.header {
position: absolute;
inset: 0;
z-index: 10;
pointer-events: none;
transition: opacity var(--motion-normal) ease;
}
.header[data-dimmed='true'] .eyebrow,
.header[data-dimmed='true'] .title h1,
.header[data-dimmed='true'] .title p {
opacity: 0.14;
}
.eyebrow {
position: absolute;
top: max(16px, env(safe-area-inset-top));
left: 16px;
z-index: 3;
margin: 0;
color: var(--text-meta);
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.14em;
}
.title {
position: absolute;
top: max(44px, calc(env(safe-area-inset-top) + 36px));
left: 50%;
z-index: 2;
width: max-content;
max-width: min(88vw, 640px);
margin: 0;
text-align: center;
transform: translateX(-50%);
pointer-events: none;
}
.title h1 {
margin: 0;
color: var(--text-primary);
font-size: clamp(22px, 6vw, 32px);
font-weight: 700;
letter-spacing: var(--tracking-display);
line-height: 1.2;
text-wrap: balance;
}
.title p {
margin: 6px 0 0;
color: var(--text-meta);
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.2em;
white-space: nowrap;
}
.title::after {
width: 32px;
height: 2px;
margin: 10px auto 0;
content: '';
display: block;
opacity: 0.9;
background: var(--accent);
border-radius: 2px;
}
@media (min-width: 768px) {
.eyebrow {
top: 48px;
left: 48px;
}
.title {
top: 48px;
max-width: min(80vw, 900px);
}
.title h1 {
font-size: clamp(38px, 4.2vw, 62px);
line-height: 1.1;
}
.title p {
margin-top: 10px;
font-size: var(--text-sm);
letter-spacing: 0.24em;
}
.title::after {
width: 48px;
height: 3px;
margin-top: 14px;
border-radius: 3px;
}
}
+31
View File
@@ -0,0 +1,31 @@
import styles from './CloudHeader.module.css';
interface CloudHeaderProps {
eyebrow: string;
title: string;
subtitle: string;
dimmed?: boolean;
layered?: boolean;
}
export function CloudHeader({
eyebrow,
title,
subtitle,
dimmed = false,
layered = false,
}: CloudHeaderProps) {
return (
<header
className={styles.header}
data-dimmed={dimmed}
data-layered={layered}
>
<p className={styles.eyebrow}>{eyebrow}</p>
<div className={styles.title} data-layered={layered}>
<h1>{title}</h1>
<p>{subtitle}</p>
</div>
</header>
);
}
+12
View File
@@ -0,0 +1,12 @@
import styles from './CloudUnavailableState.module.css';
export function CloudLoadingState() {
return (
<main className={styles.page} aria-label="正在加载词云">
<div className={styles.box}>
<h1></h1>
<p></p>
</div>
</main>
);
}
@@ -0,0 +1,24 @@
.page {
min-height: 100dvh;
display: grid;
place-items: center;
padding: 48px 16px;
background: var(--background);
}
.box {
max-width: 440px;
text-align: center;
}
.box h1 {
margin: 0 0 8px;
color: var(--text-primary);
font-size: var(--text-2xl);
letter-spacing: var(--tracking-display);
}
.box p {
margin: 0;
color: var(--text-secondary);
}
+12
View File
@@ -0,0 +1,12 @@
import styles from './CloudUnavailableState.module.css';
export function CloudUnavailableState() {
return (
<main className={styles.page} aria-label="词云未找到">
<div className={styles.box}>
<h1></h1>
<p></p>
</div>
</main>
);
}
@@ -0,0 +1,59 @@
.success {
display: grid;
gap: 12px;
justify-items: center;
padding: 32px 0;
text-align: center;
border-top: 1px solid var(--border-soft);
}
.mark {
width: 64px;
height: 64px;
color: var(--success);
background: var(--surface-warm);
border-radius: var(--radius-pill);
display: grid;
place-items: center;
font-size: 28px;
font-weight: 700;
}
.success h2 {
margin: 0;
font-size: var(--text-xl);
}
.success p {
margin: 0;
color: var(--text-secondary);
font-size: var(--text-sm);
}
.duplicateNav {
display: flex;
gap: 8px;
align-items: center;
justify-content: center;
}
.ghostButton {
min-height: 44px;
padding: 10px 16px;
color: var(--text-strong);
background: var(--surface-warm);
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 700;
}
.ghostButton:disabled {
color: var(--text-meta);
background: var(--surface);
cursor: not-allowed;
}
.count {
color: var(--text-secondary);
font-size: var(--text-sm);
}
+48
View File
@@ -0,0 +1,48 @@
import styles from './ControllerSuccessState.module.css';
interface ControllerSuccessStateProps {
targetCount: number;
selectedTargetIndex: number;
onSelectTarget: (targetIndex: number) => void;
}
export function ControllerSuccessState({
targetCount,
selectedTargetIndex,
onSelectTarget,
}: ControllerSuccessStateProps) {
const hasDuplicateMatches = targetCount > 1;
return (
<div className={styles.success} aria-live="polite" aria-label="成功状态">
<div className={styles.mark} aria-hidden="true">
</div>
<h2> · </h2>
<p> · </p>
{hasDuplicateMatches ? (
<div className={styles.duplicateNav} aria-label="重复名字切换">
<button
type="button"
className={styles.ghostButton}
onClick={() => onSelectTarget(Math.max(0, selectedTargetIndex - 1))}
disabled={selectedTargetIndex === 0}
>
</button>
<small>
{targetCount} · {selectedTargetIndex + 1} / {targetCount}
</small>
<button
type="button"
className={styles.ghostButton}
onClick={() => onSelectTarget(Math.min(targetCount - 1, selectedTargetIndex + 1))}
disabled={selectedTargetIndex === targetCount - 1}
>
</button>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,71 @@
.dockWrap {
position: absolute;
right: 0;
bottom: 0;
left: 0;
z-index: 10;
display: flex;
justify-content: center;
padding: 0 16px calc(16px + env(safe-area-inset-bottom));
}
.dock {
display: grid;
gap: 8px;
width: min(520px, 100%);
padding: 12px;
background: var(--surface);
border: 1px solid var(--border-soft);
border-radius: var(--radius-md);
box-shadow: var(--elev-raised);
}
.row {
display: flex;
gap: 8px;
}
.searchInput {
flex: 1;
min-width: 0;
min-height: 48px;
padding: 12px 15px;
color: var(--text-primary);
background: var(--background);
border: 1px solid var(--text-meta);
border-radius: var(--radius-md);
font-size: var(--text-base);
}
.searchInput::placeholder {
color: var(--text-meta);
}
.searchButton {
min-height: 48px;
padding: 12px 20px;
color: var(--text-strong);
background: var(--accent);
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 700;
display: grid;
place-items: center;
}
.notFound {
padding: 10px 12px;
background: var(--background);
border: 1px dashed var(--border);
border-radius: var(--radius-md);
}
.notFound strong {
display: block;
font-size: var(--text-base);
}
.notFound small {
color: var(--text-secondary);
font-size: var(--text-sm);
}
+65
View File
@@ -0,0 +1,65 @@
import type { FormEvent } from 'react';
import { SearchResultState } from './SearchResultState';
import styles from './FloatingSearchDock.module.css';
interface FloatingSearchDockProps {
name: string;
status: 'idle' | 'not-found' | 'found';
targetCount: number;
selectedTargetIndex: number;
onChange: (name: string) => void;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
onReset: () => void;
onSelectTarget: (targetIndex: number) => void;
}
export function FloatingSearchDock({
name,
status,
targetCount,
selectedTargetIndex,
onChange,
onSubmit,
onReset,
onSelectTarget,
}: FloatingSearchDockProps) {
return (
<div className={styles.dockWrap}>
<div className={styles.dock}>
<form className={styles.row} onSubmit={onSubmit}>
<label className="visually-hidden" htmlFor="personal-search-name">
</label>
<input
id="personal-search-name"
className={styles.searchInput}
type="text"
name="name"
autoComplete="off"
placeholder="输入你的名字"
value={name}
onChange={(event) => onChange(event.target.value)}
/>
<button className={styles.searchButton} type="submit">
</button>
</form>
{status === 'not-found' ? (
<div className={styles.notFound} aria-live="polite">
<strong></strong>
<small></small>
</div>
) : null}
{status === 'found' ? (
<SearchResultState
query={name}
targetCount={targetCount}
selectedTargetIndex={selectedTargetIndex}
onReset={onReset}
onSelectTarget={onSelectTarget}
/>
) : null}
</div>
</div>
);
}
@@ -0,0 +1,36 @@
.button {
position: absolute;
top: max(16px, env(safe-area-inset-top));
right: 16px;
z-index: 4;
display: grid;
width: 40px;
height: 40px;
padding: 0;
place-items: center;
border: 1px solid rgba(200, 200, 193, 0.82);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.78);
color: var(--text-primary);
box-shadow: var(--elev-raised);
backdrop-filter: blur(8px);
transition:
background var(--motion-fast) ease,
color var(--motion-fast) ease,
transform var(--motion-fast) ease;
}
.button:hover {
background: var(--surface);
}
.button:active {
transform: scale(0.94);
}
@media (min-width: 768px) {
.button {
top: 48px;
right: 48px;
}
}
+72
View File
@@ -0,0 +1,72 @@
import { Maximize2, Minimize2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import styles from './FullscreenButton.module.css';
type FullscreenDocument = Document & {
webkitExitFullscreen?: () => Promise<void> | void;
webkitFullscreenElement?: Element | null;
};
type FullscreenElement = HTMLElement & {
webkitRequestFullscreen?: () => Promise<void> | void;
};
function getFullscreenElement() {
const fullscreenDocument = document as FullscreenDocument;
return document.fullscreenElement ?? fullscreenDocument.webkitFullscreenElement ?? null;
}
export function FullscreenButton() {
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
const syncFullscreenState = () => {
setIsFullscreen(Boolean(getFullscreenElement()));
};
syncFullscreenState();
document.addEventListener('fullscreenchange', syncFullscreenState);
document.addEventListener('webkitfullscreenchange', syncFullscreenState);
return () => {
document.removeEventListener('fullscreenchange', syncFullscreenState);
document.removeEventListener('webkitfullscreenchange', syncFullscreenState);
};
}, []);
const toggleFullscreen = async () => {
const fullscreenDocument = document as FullscreenDocument;
try {
if (getFullscreenElement()) {
const exitFullscreen = document.exitFullscreen ?? fullscreenDocument.webkitExitFullscreen;
await exitFullscreen?.call(document);
return;
}
const target = document.documentElement as FullscreenElement;
const requestFullscreen = target.requestFullscreen ?? target.webkitRequestFullscreen;
await requestFullscreen?.call(target);
} catch {
// The browser can deny a fullscreen request; preserve the current screen state.
}
};
const label = isFullscreen ? '退出全屏' : '进入全屏';
const Icon = isFullscreen ? Minimize2 : Maximize2;
return (
<button
className={styles.button}
type="button"
aria-label={label}
aria-pressed={isFullscreen}
title={label}
onClick={() => {
void toggleFullscreen();
}}
>
<Icon aria-hidden="true" size={20} strokeWidth={1.8} />
</button>
);
}
+54
View File
@@ -0,0 +1,54 @@
.panel {
position: absolute;
right: 48px;
bottom: 48px;
z-index: 10;
display: flex;
gap: 16px;
align-items: center;
width: min(360px, calc(100vw - 96px));
padding: 16px;
background: var(--surface);
border: 1px solid var(--border-soft);
border-radius: var(--radius-md);
box-shadow: var(--elev-raised);
}
.code {
width: 168px;
height: 168px;
flex: none;
display: block;
border: 1px solid var(--border-soft);
border-radius: var(--radius-sm);
background: #ffffff;
}
.content {
min-width: 0;
}
.content h2 {
margin: 0 0 4px;
font-size: var(--text-lg);
}
.content p {
margin: 0;
color: var(--text-secondary);
font-size: var(--text-sm);
}
@media (max-width: 760px) {
.panel {
right: 16px;
left: 16px;
bottom: 16px;
width: auto;
}
.code {
width: 120px;
height: 120px;
}
}
+60
View File
@@ -0,0 +1,60 @@
import { useEffect, useState } from 'react';
import { toDataURL } from 'qrcode';
import styles from './QRPanel.module.css';
interface QRPanelProps {
cloudId: string;
}
export function QRPanel({ cloudId }: QRPanelProps) {
const [qrDataUrl, setQRDataUrl] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
const url = new URL(`/control/${encodeURIComponent(cloudId)}`, window.location.origin).href;
toDataURL(url, {
errorCorrectionLevel: 'M',
margin: 1,
width: 336,
color: {
dark: '#211922',
light: '#ffffff',
},
})
.then((dataUrl) => {
if (cancelled) {
return;
}
setQRDataUrl(dataUrl);
})
.catch(() => {
if (!cancelled) {
setQRDataUrl(null);
}
});
return () => {
cancelled = true;
};
}, [cloudId]);
return (
<aside className={styles.panel} aria-label="扫码互动">
{qrDataUrl ? (
<img
className={styles.code}
src={qrDataUrl}
alt="扫码进入遥控器"
/>
) : (
<div className={styles.code} aria-hidden="true" />
)}
<div className={styles.content}>
<h2></h2>
<p></p>
</div>
</aside>
);
}
@@ -0,0 +1,39 @@
.result {
padding: 10px 12px;
background: var(--background);
border: 1px dashed var(--border);
border-radius: var(--radius-md);
}
.result strong {
display: block;
font-size: var(--text-base);
}
.result small {
display: block;
color: var(--text-secondary);
font-size: var(--text-sm);
}
.resultRow {
display: flex;
gap: 8px;
align-items: center;
margin-top: 8px;
}
.ghostButton {
min-height: 44px;
padding: 10px 16px;
color: var(--text-strong);
background: var(--surface-warm);
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 700;
}
.count {
color: var(--text-secondary);
font-size: var(--text-sm);
}
+58
View File
@@ -0,0 +1,58 @@
import styles from './SearchResultState.module.css';
interface SearchResultStateProps {
query: string;
targetCount: number;
selectedTargetIndex: number;
onReset: () => void;
onSelectTarget: (targetIndex: number) => void;
}
export function SearchResultState({
query,
targetCount,
selectedTargetIndex,
onReset,
onSelectTarget,
}: SearchResultStateProps) {
const hasMultipleTargets = targetCount > 1;
return (
<div className={styles.result} aria-live="polite">
<strong> · {query}</strong>
<small>{hasMultipleTargets ? `找到 ${targetCount}` : '你在这里'}</small>
<div className={styles.resultRow}>
<button
type="button"
className={styles.ghostButton}
onClick={onReset}
>
</button>
{hasMultipleTargets ? (
<>
<button
type="button"
className={styles.ghostButton}
onClick={() => onSelectTarget(Math.max(0, selectedTargetIndex - 1))}
disabled={selectedTargetIndex === 0}
>
</button>
<button
type="button"
className={styles.ghostButton}
onClick={() => onSelectTarget(Math.min(targetCount - 1, selectedTargetIndex + 1))}
disabled={selectedTargetIndex === targetCount - 1}
>
</button>
<small className={styles.count}>
{selectedTargetIndex + 1} / {targetCount}
</small>
</>
) : null}
</div>
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
.frost {
position: absolute;
top: 20px;
left: 50%;
z-index: 1;
width: min(88vw, 660px);
height: 132px;
transform: translateX(-50%);
background: radial-gradient(
120% 120% at 50% 50%,
rgba(255, 255, 255, 0.34) 0%,
rgba(255, 255, 255, 0.16) 48%,
rgba(255, 255, 255, 0.04) 78%,
rgba(255, 255, 255, 0) 100%
);
backdrop-filter: blur(22px) saturate(180%) brightness(1.12) contrast(1.05);
-webkit-backdrop-filter: blur(22px) saturate(180%) brightness(1.12) contrast(1.05);
border: 1px solid rgba(255, 255, 255, 0.4);
border-radius: 24px;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.5),
0 10px 24px rgba(0, 0, 0, 0.06);
pointer-events: none;
}
@media (min-width: 768px) {
.frost {
top: 24px;
width: min(80vw, 760px);
height: 150px;
border-radius: 28px;
}
}
+5
View File
@@ -0,0 +1,5 @@
import styles from './TitleFrost.module.css';
export function TitleFrost() {
return <div aria-hidden="true" className={styles.frost} />;
}
+108
View File
@@ -0,0 +1,108 @@
.canvas {
position: absolute;
inset: 0;
overflow: hidden;
touch-action: none;
cursor: grab;
user-select: none;
}
.canvas:active {
cursor: grabbing;
}
.stageFrame {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
.stageBox {
position: relative;
width: 100%;
height: 100%;
}
.stageBox[data-fitted='true'] {
width: min(100%, calc(100dvh * var(--source-aspect)));
height: auto;
max-height: 100%;
aspect-ratio: var(--source-aspect);
}
.stage {
width: 100%;
height: 100%;
transform-origin: 0 0;
will-change: transform;
transition: transform var(--motion-focus) var(--ease-camera);
}
.stage[data-phase='returning'] {
transition: transform var(--motion-return) var(--ease-camera);
}
.sourcePreview,
.sourceVector {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.sourcePreview {
display: block;
object-fit: fill;
}
.sourceVector svg {
display: block;
width: 100%;
height: 100%;
}
.sourceVector g[data-word-id] {
transform-box: fill-box;
transform-origin: center;
transition:
opacity var(--motion-normal) ease,
filter var(--motion-fast) ease,
transform var(--motion-focus) var(--ease-camera);
}
.sourceVector[data-has-active='true'] g[data-word-id] {
opacity: var(--dimmed-opacity);
}
.sourceVector[data-has-active='true'] g[data-active='true'] {
opacity: 1;
filter: drop-shadow(0 2px 12px rgba(230, 0, 35, 0.22));
transform: scale(var(--target-scale));
}
.sourceVector[data-has-active='true'] g[data-active='true'] path {
fill: var(--accent);
}
.word {
position: absolute;
color: var(--cloud-foreground);
font-family: var(--font-art);
white-space: nowrap;
opacity: var(--base-opacity);
transition:
opacity var(--motion-normal) ease,
transform var(--motion-focus) var(--ease-camera),
color var(--motion-fast) ease;
}
.word[data-dimmed='true'] {
opacity: var(--dimmed-opacity);
}
.word[data-active='true'] {
z-index: 5;
color: var(--cloud-accent);
text-shadow: 0 2px 12px rgba(230, 0, 35, 0.16);
}
+176
View File
@@ -0,0 +1,176 @@
import { render, screen, waitFor } from '@testing-library/react';
import { afterEach, vi } from 'vitest';
import { WordCloudCanvas } from './WordCloudCanvas';
import type { WordCloudWord } from '../types/cloud';
const words: WordCloudWord[] = [
{
id: 'horizontal',
name: '魏大正',
x: 50,
y: 50,
fontSize: 83,
tier: 4,
},
{
id: 'vertical',
name: '梅瑞麟',
x: 25,
y: 25,
fontSize: 83,
rotation: 90,
tier: 4,
},
];
const measuredWords: Array<WordCloudWord & { sourceBox: { width: number; height: number } }> = [
{ ...words[0], sourceBox: { width: 249, height: 75 } },
{ ...words[1], sourceBox: { width: 76, height: 249 } },
];
const sourceSvg = [
'<svg width="4000" height="3563" viewBox="0 0 4000 3563" xmlns="http://www.w3.org/2000/svg">',
'<rect width="100%" height="100%" fill="white"/>',
'<path id="path-1" d="M0 0"/>',
'<path id="path-2" d="M0 0"/>',
'<path id="path-3" d="M0 0"/>',
'<path id="path-4" d="M0 0"/>',
'<path id="path-5" d="M0 0"/>',
'</svg>',
].join('');
const wholeWordSourceSvg = [
'<svg width="4000" height="3563" viewBox="0 0 4000 3563" xmlns="http://www.w3.org/2000/svg">',
'<rect width="100%" height="100%" fill="white"/>',
'<path id="word-1" d="M0 0"/>',
'<path id="word-2" d="M0 0"/>',
'</svg>',
].join('');
const vectorWords: WordCloudWord[] = [
{ ...words[0], id: 'first', name: '王小明' },
{ ...words[1], id: 'second', name: ' 曹益' },
];
afterEach(() => {
vi.unstubAllGlobals();
document.documentElement.style.removeProperty('--accent');
});
describe('WordCloudCanvas', () => {
it('anchors every imported word at its measured bounding-box center', () => {
render(
<WordCloudCanvas
words={words}
sourceCanvas={{ width: 4000, height: 3563 }}
viewport={{ width: 1280, height: 720 }}
/>,
);
expect(screen.getByText('魏大正')).toHaveStyle({
transform: 'translate(-50%, -50%)',
});
expect(screen.getByText('梅瑞麟')).toHaveStyle({
transform: 'translate(-50%, -50%) rotate(90deg)',
});
});
it('keeps a rotated target centered while applying its focus scale', () => {
render(
<WordCloudCanvas
words={words}
activeIds={new Set(['vertical'])}
sourceCanvas={{ width: 4000, height: 3563 }}
viewport={{ width: 1280, height: 720 }}
/>,
);
expect(screen.getByText('梅瑞麟')).toHaveStyle({
transform: 'translate(-50%, -50%) rotate(90deg) scale(1.18)',
});
});
it('uses source bounding boxes for the text line box', () => {
render(
<WordCloudCanvas
words={measuredWords}
sourceCanvas={{ width: 4000, height: 3563 }}
viewport={{ width: 1280, height: 720 }}
/>,
);
const scale = (720 * (4000 / 3563)) / 4000;
expect(parseFloat(screen.getByText('魏大正').style.lineHeight)).toBeCloseTo(75 * scale);
expect(parseFloat(screen.getByText('梅瑞麟').style.lineHeight)).toBeCloseTo(76 * scale);
});
it('renders source SVG paths as independent word groups', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
text: async () => sourceSvg,
}));
const vectorProps: React.ComponentProps<typeof WordCloudCanvas> & { sourceSvg: string } = {
words: vectorWords,
sourceCanvas: { width: 4000, height: 3563 },
viewport: { width: 1280, height: 720 },
sourceSvg: '/wordclouds/imported.svg',
};
const { container } = render(<WordCloudCanvas {...vectorProps} />);
await waitFor(() => {
expect(container.querySelectorAll('g[data-word-id]')).toHaveLength(2);
});
expect(container.querySelector('[data-word-id="first"]')?.querySelectorAll('path')).toHaveLength(3);
expect(container.querySelector('[data-word-id="second"]')?.querySelectorAll('path')).toHaveLength(2);
});
it('renders one complete SVG path per imported word when provided by the source', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
text: async () => wholeWordSourceSvg,
}));
const { container } = render(
<WordCloudCanvas
words={vectorWords}
sourceCanvas={{ width: 4000, height: 3563 }}
viewport={{ width: 1280, height: 720 }}
sourceSvg="/wordclouds/whole-word.svg"
/>,
);
await waitFor(() => {
expect(container.querySelectorAll('g[data-word-id]')).toHaveLength(2);
});
expect(container.querySelector('[data-word-id="first"]')?.querySelectorAll('path')).toHaveLength(1);
expect(container.querySelector('[data-word-id="second"]')?.querySelectorAll('path')).toHaveLength(1);
});
it('fills active source SVG paths with the interface accent color', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
text: async () => sourceSvg,
}));
const { container } = render(
<WordCloudCanvas
words={vectorWords}
activeIds={new Set(['first'])}
sourceCanvas={{ width: 4000, height: 3563 }}
viewport={{ width: 1280, height: 720 }}
sourceSvg="/wordclouds/imported.svg"
/>,
);
await waitFor(() => {
expect(container.querySelector('[data-word-id="first"] path')).not.toBeNull();
});
expect(
getComputedStyle(container.querySelector('[data-word-id="first"] path')!).fill,
).toBe('var(--accent)');
});
});
+211
View File
@@ -0,0 +1,211 @@
import { useEffect, useRef, useState } from 'react';
import type {
CSSProperties,
KeyboardEventHandler,
MouseEventHandler,
RefObject,
WheelEventHandler,
} from 'react';
import { FOCUS_CONFIG } from '../config/focus';
import type { FocusPhase } from '../hooks/useWordCloudFocus';
import type { WordCloudWord } from '../types/cloud';
import { groupSvgPathsByWord } from '../lib/sourceSvg';
import styles from './WordCloudCanvas.module.css';
import type { FocusTransform } from '../lib/wordCloud';
interface WordCloudCanvasProps {
words: WordCloudWord[];
phase?: FocusPhase;
transform?: FocusTransform;
activeIds?: Set<string>;
cloudRef?: RefObject<HTMLDivElement>;
onPointerDown?: MouseEventHandler<HTMLDivElement>;
onPointerMove?: MouseEventHandler<HTMLDivElement>;
onPointerUp?: MouseEventHandler<HTMLDivElement>;
onWheel?: WheelEventHandler<HTMLDivElement>;
onKeyDown?: KeyboardEventHandler<HTMLDivElement>;
sourceCanvas?: { width: number; height: number };
sourceSvg?: string;
viewport?: { width: number; height: number };
}
export function WordCloudCanvas({
words,
phase = 'idle',
transform = { x: 0, y: 0, scale: 1 },
activeIds = new Set<string>(),
cloudRef,
onPointerDown,
onPointerMove,
onPointerUp,
onWheel,
onKeyDown,
sourceCanvas,
sourceSvg,
viewport = { width: 0, height: 0 },
}: WordCloudCanvasProps) {
const [sourceMarkup, setSourceMarkup] = useState<string | null>(null);
const [sourceLoadFailed, setSourceLoadFailed] = useState(false);
const sourceVectorRef = useRef<HTMLDivElement | null>(null);
const sourceAspect = sourceCanvas
? sourceCanvas.width / sourceCanvas.height
: null;
const fallbackWidth = typeof window === 'undefined' ? 0 : window.innerWidth;
const fallbackHeight = typeof window === 'undefined' ? 0 : window.innerHeight;
const fitWidth = sourceAspect
? Math.min(
viewport.width || fallbackWidth,
(viewport.height || fallbackHeight) * sourceAspect,
)
: viewport.width || fallbackWidth;
const cloudScale = sourceCanvas && sourceCanvas.width > 0
? fitWidth / sourceCanvas.width
: 1;
const hasActiveTarget = activeIds.size > 0;
useEffect(() => {
let cancelled = false;
if (!sourceSvg) {
setSourceMarkup(null);
setSourceLoadFailed(false);
return undefined;
}
setSourceMarkup(null);
setSourceLoadFailed(false);
void fetch(sourceSvg)
.then(async (response) => {
if (!response.ok) {
throw new Error(`Unable to load source SVG: ${response.status}`);
}
return groupSvgPathsByWord(await response.text(), words);
})
.then((markup) => {
if (!cancelled) {
setSourceMarkup(markup);
}
})
.catch(() => {
if (!cancelled) {
setSourceLoadFailed(true);
}
});
return () => {
cancelled = true;
};
}, [sourceSvg, words]);
useEffect(() => {
const sourceVector = sourceVectorRef.current;
if (!sourceVector) {
return;
}
sourceVector.querySelectorAll<SVGGElement>('g[data-word-id]').forEach((group) => {
group.dataset.active = String(activeIds.has(group.dataset.wordId ?? ''));
});
}, [activeIds, sourceMarkup]);
const showSourcePreview = Boolean(sourceSvg && !sourceMarkup && !sourceLoadFailed);
return (
<div
className={styles.canvas}
tabIndex={0}
role="application"
aria-label="词云画面,可以使用方向键平移,使用加号或减号缩放"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onWheel={onWheel}
onKeyDown={onKeyDown}
>
<div className={styles.stageFrame}>
<div
ref={cloudRef}
className={styles.stageBox}
data-fitted={Boolean(sourceCanvas)}
style={
sourceAspect
? ({ '--source-aspect': sourceAspect } as CSSProperties)
: undefined
}
>
<div
className={styles.stage}
data-phase={phase}
style={{
transform: `translate3d(${transform.x}px, ${transform.y}px, 0) scale(${transform.scale})`,
}}
>
{showSourcePreview ? (
<img
className={styles.sourcePreview}
src={sourceSvg}
alt=""
aria-hidden="true"
/>
) : null}
{sourceMarkup ? (
<div
ref={sourceVectorRef}
className={styles.sourceVector}
data-has-active={hasActiveTarget}
style={{
'--dimmed-opacity': FOCUS_CONFIG.dimmedOpacity,
'--target-scale': FOCUS_CONFIG.targetScale,
} as CSSProperties}
dangerouslySetInnerHTML={{ __html: sourceMarkup }}
/>
) : null}
{!sourceSvg || sourceLoadFailed ? words.map((word) => {
const isActive = activeIds.has(word.id);
const wordTransform = [
'translate(-50%, -50%)',
word.rotation ? `rotate(${word.rotation}deg)` : '',
isActive ? `scale(${FOCUS_CONFIG.targetScale})` : '',
].filter(Boolean).join(' ');
const sourceLineHeight = sourceCanvas && word.sourceBox
? (word.rotation ? word.sourceBox.width : word.sourceBox.height) * cloudScale
: undefined;
return (
<span
key={word.id}
className={styles.word}
data-active={isActive}
data-dimmed={hasActiveTarget && !isActive}
style={{
left: `${word.x}%`,
top: `${word.y}%`,
fontSize: sourceCanvas
? `${word.fontSize * cloudScale}px`
: `calc(${word.fontSize}px * var(--cloud-scale))`,
lineHeight: sourceLineHeight
? `${sourceLineHeight}px`
: undefined,
opacity: undefined,
'--base-opacity': word.tier === 1 ? 0.55 : word.tier === 2 ? 0.7 : word.tier === 3 ? 0.85 : 0.94,
'--dimmed-opacity': FOCUS_CONFIG.dimmedOpacity,
'--target-scale': FOCUS_CONFIG.targetScale,
'--cloud-foreground': 'var(--text-primary)',
'--cloud-accent': 'var(--accent)',
transform: wordTransform,
} as CSSProperties}
>
{word.name}
</span>
);
}) : null}
</div>
</div>
</div>
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
export const FOCUS_CONFIG = {
focusDuration: 720,
holdDuration: 5500,
returnDuration: 680,
minScale: 1.35,
maxScale: 2.5,
manualMaxScale: 3,
targetPadding: 160,
targetScale: 1.18,
dimmedOpacity: 0.16,
nearbyThreshold: 25,
queueSize: 5,
panStep: 48,
zoomStep: 1.08,
channelName: 'wordcloud-demo',
} as const;
+10
View File
@@ -0,0 +1,10 @@
import { describe, expect, it } from 'vitest';
import { wordCloudApiUrl } from './wordcloudApi';
describe('wordCloudApiUrl', () => {
it('uses the same-origin WordCloud gateway by default', () => {
expect(wordCloudApiUrl('/api/jobs/example/locations?name=')).toBe(
'/wordcloud-api/api/jobs/example/locations?name=',
);
});
});
+23
View File
@@ -0,0 +1,23 @@
const DEFAULT_API_BASE_URL = '/wordcloud-api';
const DEFAULT_JOB_ID = 'bd1f240adcb4458f857c40ac427122c6';
function trimTrailingSlashes(value: string): string {
return value.replace(/\/+$/, '');
}
export const WORDCLOUD_API_BASE_URL = trimTrailingSlashes(
import.meta.env.VITE_WORDCLOUD_API_BASE_URL?.trim() || DEFAULT_API_BASE_URL,
);
export const DEFAULT_WORDCLOUD_JOB_ID =
import.meta.env.VITE_WORDCLOUD_DEFAULT_JOB_ID?.trim() || DEFAULT_JOB_ID;
export function resolveWordCloudJobId(cloudId: string): string {
return cloudId === 'imported' ? DEFAULT_WORDCLOUD_JOB_ID : cloudId;
}
export function wordCloudApiUrl(path: string): string {
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
return `${WORDCLOUD_API_BASE_URL}${normalizedPath}`;
}
+84
View File
@@ -0,0 +1,84 @@
import type { WordCloud } from '../../types/cloud';
export const demoCloud: WordCloud = {
id: 'demo',
eyebrow: 'LIVE ARCHIVE · 2026',
title: '2026 毕业典礼 · 全体名单',
subtitle: 'CLASS OF 2026 · WORD CLOUD',
caption: 'THIS WALL IS MADE OF EVERYONE HERE',
theme: {
background: '#ffffff',
foreground: '#211922',
accent: '#e60023',
},
words: [
{ id: 'wang-xiaoming-1', name: '王小明', x: 50, y: 44, fontSize: 52, tier: 4 },
{ id: 'lin-jiayi', name: '林嘉怡', x: 32, y: 30, fontSize: 34, tier: 3 },
{ id: 'sophia-1', name: 'Sophia', x: 68, y: 30, fontSize: 34, tier: 3 },
{ id: 'li-xin', name: '李欣', x: 24, y: 58, fontSize: 34, tier: 3 },
{ id: 'kevin', name: 'Kevin', x: 76, y: 60, fontSize: 34, tier: 3 },
{ id: 'chen-chen', name: '陈晨', x: 50, y: 66, fontSize: 34, tier: 3 },
{ id: 'alex-1', name: 'Alex', x: 38, y: 52, fontSize: 22, tier: 2 },
{ id: 'lily', name: 'Lily', x: 62, y: 52, fontSize: 22, tier: 2 },
{ id: 'zhang-yu', name: '张雨', x: 44, y: 24, fontSize: 22, tier: 2 },
{ id: 'daniel', name: 'Daniel', x: 58, y: 76, fontSize: 22, tier: 2 },
{ id: 'zhao-tianyu', name: '赵天宇', x: 18, y: 40, fontSize: 22, tier: 2 },
{ id: 'emma', name: 'Emma', x: 82, y: 42, fontSize: 22, tier: 2 },
{ id: 'su-wanqing', name: '苏晚晴', x: 30, y: 76, fontSize: 22, tier: 2 },
{ id: 'zhou-zimo', name: '周子墨', x: 70, y: 78, fontSize: 22, tier: 2 },
{ id: 'olivia', name: 'Olivia', x: 50, y: 14, fontSize: 22, tier: 2 },
{ id: 'shen-zhiyao', name: '沈知遥', x: 50, y: 88, fontSize: 22, tier: 2 },
{ id: 'wangzihan', name: '王梓涵', x: 14, y: 66, fontSize: 15, tier: 1 },
{ id: 'li-yutong', name: '李雨桐', x: 86, y: 66, fontSize: 15, tier: 1 },
{ id: 'zhang-haoran', name: '张浩然', x: 22, y: 22, fontSize: 15, tier: 1 },
{ id: 'liu-yifei', name: '刘亦菲', x: 78, y: 20, fontSize: 15, tier: 1 },
{ id: 'chen-zihao', name: '陈子豪', x: 12, y: 52, fontSize: 15, tier: 1 },
{ id: 'yang-chenxi', name: '杨晨曦', x: 88, y: 52, fontSize: 15, tier: 1 },
{ id: 'huang-junjie', name: '黄俊杰', x: 36, y: 84, fontSize: 15, tier: 1 },
{ id: 'wu-siyuan', name: '吴思远', x: 64, y: 84, fontSize: 15, tier: 1 },
{ id: 'xu-ruoxuan', name: '徐若瑄', x: 26, y: 44, fontSize: 15, tier: 1 },
{ id: 'sun-haoyu', name: '孙浩宇', x: 74, y: 46, fontSize: 15, tier: 1 },
{ id: 'ma-xiaotong', name: '马晓彤', x: 42, y: 38, fontSize: 15, tier: 1 },
{ id: 'zhu-zirui', name: '朱子睿', x: 58, y: 38, fontSize: 15, tier: 1 },
{ id: 'hu-wenxuan', name: '胡文轩', x: 34, y: 64, fontSize: 15, tier: 1 },
{ id: 'guo-meiling', name: '郭美玲', x: 66, y: 64, fontSize: 15, tier: 1 },
{ id: 'he-jiayi', name: '何佳怡', x: 46, y: 58, fontSize: 15, tier: 1 },
{ id: 'luo-zihan', name: '罗子涵', x: 55, y: 59, fontSize: 15, tier: 1 },
{ id: 'james', name: 'James', x: 20, y: 82, fontSize: 15, tier: 1 },
{ id: 'mary', name: 'Mary', x: 80, y: 82, fontSize: 15, tier: 1 },
{ id: 'john', name: 'John', x: 16, y: 30, fontSize: 15, tier: 1 },
{ id: 'grace', name: 'Grace', x: 84, y: 30, fontSize: 15, tier: 1 },
{ id: 'noah', name: 'Noah', x: 40, y: 10, fontSize: 15, tier: 1 },
{ id: 'mia', name: 'Mia', x: 60, y: 10, fontSize: 15, tier: 1 },
{ id: 'ai', name: '艾', x: 50, y: 94, fontSize: 15, tier: 1 },
{ id: 'alexandrina', name: 'Alexandrina', x: 68, y: 14, fontSize: 22, tier: 2 },
{ id: 'ouyang-yutongxi', name: '欧阳雨桐溪', x: 30, y: 90, fontSize: 22, tier: 2 },
{ id: 'wang-xiaoming-2', name: '王小明', x: 70, y: 68, fontSize: 22, tier: 2 },
{ id: 'sophia-2', name: 'Sophia', x: 52, y: 32, fontSize: 22, tier: 2 },
{ id: 'alex-2', name: 'Alex', x: 18, y: 20, fontSize: 15, tier: 1 },
{ id: 'chen-leyao', name: '陈乐瑶', x: 25, y: 12, fontSize: 15, tier: 1 },
{ id: 'chen-zhiyuan', name: '陈志远', x: 75, y: 12, fontSize: 15, tier: 1 },
{ id: 'huang-sihan', name: '黄思涵', x: 32, y: 16, fontSize: 15, tier: 1 },
{ id: 'wu-yuze', name: '吴雨泽', x: 68, y: 20, fontSize: 15, tier: 1 },
{ id: 'xu-qinghe', name: '许清和', x: 25, y: 34, fontSize: 15, tier: 1 },
{ id: 'chen-zhuoyuan', name: '陈卓远', x: 88, y: 38, fontSize: 15, tier: 1 },
{ id: 'li-chengze', name: '李承泽', x: 10, y: 22, fontSize: 15, tier: 1 },
{ id: 'zhou-yuxin', name: '周雨欣', x: 90, y: 72, fontSize: 15, tier: 1 },
{ id: 'lin-shuomei', name: '林烁玫', x: 15, y: 78, fontSize: 15, tier: 1 },
{ id: 'chen-jiahe', name: '陈嘉禾', x: 85, y: 90, fontSize: 15, tier: 1 },
{ id: 'huang-kexin', name: '黄可欣', x: 40, y: 74, fontSize: 15, tier: 1 },
{ id: 'luo-siyuan', name: '罗思远', x: 55, y: 84, fontSize: 15, tier: 1 },
{ id: 'zhang-jingyi', name: '张静怡', x: 62, y: 26, fontSize: 15, tier: 1 },
{ id: 'lin-yinuo', name: '林一诺', x: 38, y: 94, fontSize: 15, tier: 1 },
{ id: 'liu-xinran', name: '刘欣然', x: 62, y: 90, fontSize: 15, tier: 1 },
{ id: 'gao-zimo', name: '高子墨', x: 20, y: 90, fontSize: 15, tier: 1 },
{ id: 'xu-jingxing', name: '许景行', x: 80, y: 62, fontSize: 15, tier: 1 },
{ id: 'shen-yizhou', name: '沈亦舟', x: 40, y: 30, fontSize: 15, tier: 1 },
{ id: 'chen-siyuan', name: '陈思远', x: 60, y: 70, fontSize: 15, tier: 1 },
{ id: 'han-muchen', name: '韩沐宸', x: 12, y: 84, fontSize: 15, tier: 1 },
{ id: 'song-zhixia', name: '宋知夏', x: 88, y: 84, fontSize: 15, tier: 1 },
{ id: 'jiang-yiran', name: '姜亦然', x: 28, y: 58, fontSize: 15, tier: 1 },
{ id: 'bai-ruoxi', name: '白若溪', x: 72, y: 92, fontSize: 15, tier: 1 },
{ id: 'qin-wanzhou', name: '秦晚舟', x: 45, y: 80, fontSize: 15, tier: 1 },
],
};
+40
View File
@@ -0,0 +1,40 @@
import importedResultJson from './importedWordcloud.json';
import {
isImportedWordLocationResult,
mapImportedWordLocations,
} from '../../lib/wordcloudImport';
import type { WordCloud } from '../../types/cloud';
const importedResult = isImportedWordLocationResult(importedResultJson)
? importedResultJson
: null;
const jobId = importedResult?.job_id ?? 'imported';
export const importedCloud: WordCloud | null = importedResult
? {
id: 'imported',
eyebrow: 'LIVE ARCHIVE · 2026',
title: '2026 毕业典礼 · 全体名单',
subtitle: 'CLASS OF 2026 · WORD CLOUD',
caption: 'REUSED FROM WORDCLOUD DATABASE',
theme: {
background: '#ffffff',
foreground: '#211922',
accent: '#e60023',
},
sourceCanvas: importedResult
? {
width: importedResult.canvas_width,
height: importedResult.canvas_height,
}
: undefined,
sourceSvg: '/wordclouds/40588babde574efeb9643f4af05c8d43.svg',
words: mapImportedWordLocations(importedResult, {
jobId,
eyebrow: 'LIVE ARCHIVE · 2026',
title: '2026 毕业典礼 · 全体名单',
subtitle: 'CLASS OF 2026 · WORD CLOUD',
}),
}
: null;
File diff suppressed because one or more lines are too long
+8
View File
@@ -0,0 +1,8 @@
import type { WordCloud } from '../../types/cloud';
import { demoCloud } from './demo';
const clouds: WordCloud[] = [demoCloud];
export function getCloudById(cloudId: string): WordCloud | undefined {
return clouds.find((cloud) => cloud.id === cloudId);
}
+45
View File
@@ -0,0 +1,45 @@
import { focusQueueReducer } from '../focusQueue';
import type { FocusEvent } from '../../types/channel';
import type { FocusQueueState } from '../focusQueue';
const event = (id: string, name: string): FocusEvent => ({
type: 'FOCUS_NAME',
cloudId: 'demo',
name,
messageId: id,
sentAt: 1000,
});
describe('focus queue', () => {
it('starts the first name and queues later names without interruption', () => {
let state: FocusQueueState = { current: null, pending: [] };
state = focusQueueReducer(state, { type: 'ENQUEUE', event: event('1', '王小明') });
state = focusQueueReducer(state, { type: 'ENQUEUE', event: event('2', 'Sophia') });
expect(state.current?.messageId).toBe('1');
expect(state.pending.map(({ messageId }) => messageId)).toEqual(['2']);
});
it('advances to the next queued name after the current focus completes', () => {
let state: FocusQueueState = { current: null, pending: [] };
state = focusQueueReducer(state, { type: 'ENQUEUE', event: event('1', '王小明') });
state = focusQueueReducer(state, { type: 'ENQUEUE', event: event('2', 'Kevin') });
state = focusQueueReducer(state, { type: 'ADVANCE' });
expect(state.current?.messageId).toBe('2');
});
it('ignores incoming names after the queue limit is reached', () => {
let state: FocusQueueState = { current: null, pending: [] };
state = focusQueueReducer(state, { type: 'ENQUEUE', event: event('1', '王小明') });
state = focusQueueReducer(state, { type: 'ENQUEUE', event: event('2', 'Sophia') });
state = focusQueueReducer(state, { type: 'ENQUEUE', event: event('3', 'Kevin') });
state = focusQueueReducer(state, { type: 'ENQUEUE', event: event('4', 'Alex') });
state = focusQueueReducer(state, { type: 'ENQUEUE', event: event('5', 'Mary') });
state = focusQueueReducer(state, { type: 'ENQUEUE', event: event('6', 'John') });
expect(state.current?.messageId).toBe('1');
expect(state.pending).toHaveLength(4);
});
});
@@ -0,0 +1,33 @@
import { act, renderHook } from '@testing-library/react';
import { useWordCloudFocus } from '../useWordCloudFocus';
import type { WordCloudWord } from '../../types/cloud';
const word = (id: string, name: string, x: number, y: number): WordCloudWord => ({
id,
name,
x,
y,
fontSize: 28,
tier: 2,
});
describe('useWordCloudFocus', () => {
it('focuses the requested occurrence of a duplicate name', () => {
const { result } = renderHook(() =>
useWordCloudFocus({
words: [
word('sophia-1', 'Sophia', 68, 30),
word('sophia-2', 'Sophia', 18, 20),
],
autoReturn: true,
}),
);
act(() => {
result.current.focusName('Sophia', 1);
});
expect(result.current.searchResult?.selectedTargetIndex).toBe(1);
expect(result.current.activeIds.has('sophia-2')).toBe(true);
});
});
+44
View File
@@ -0,0 +1,44 @@
import { FOCUS_CONFIG } from '../config/focus';
import type { FocusEvent } from '../types/channel';
export interface FocusQueueState {
current: FocusEvent | null;
pending: FocusEvent[];
}
export type FocusQueueAction =
| { type: 'ENQUEUE'; event: FocusEvent; maxSize?: number }
| { type: 'ADVANCE' }
| { type: 'CLEAR' };
export function focusQueueReducer(
state: FocusQueueState,
action: FocusQueueAction,
): FocusQueueState {
const maxSize = 'maxSize' in action && action.maxSize !== undefined
? action.maxSize
: FOCUS_CONFIG.queueSize;
switch (action.type) {
case 'ENQUEUE': {
if (state.current || state.pending.length > 0) {
if (state.current !== null && state.pending.length >= maxSize - 1) {
return state;
}
return { ...state, pending: [...state.pending, action.event] };
}
return { ...state, current: action.event };
}
case 'ADVANCE':
return {
current: state.pending[0] ?? null,
pending: state.pending.slice(1),
};
case 'CLEAR':
return { current: null, pending: [] };
default:
return state;
}
}
+53
View File
@@ -0,0 +1,53 @@
import { useEffect, useState } from 'react';
import { getCloudById } from '../data/clouds';
import { loadRemoteWordCloud } from '../lib/wordcloudApi';
import type { WordCloud } from '../types/cloud';
export type CloudLoadStatus = 'loading' | 'ready' | 'unavailable';
interface CloudLoadState {
status: CloudLoadStatus;
cloud: WordCloud | null;
}
function getInitialState(cloudId: string): CloudLoadState {
const localCloud = getCloudById(cloudId);
return localCloud
? { status: 'ready', cloud: localCloud }
: { status: 'loading', cloud: null };
}
export function useCloud(cloudId: string): CloudLoadState {
const [state, setState] = useState<CloudLoadState>(() => getInitialState(cloudId));
useEffect(() => {
const localCloud = getCloudById(cloudId);
if (localCloud) {
setState({ status: 'ready', cloud: localCloud });
return undefined;
}
let cancelled = false;
setState({ status: 'loading', cloud: null });
void loadRemoteWordCloud(cloudId)
.then((cloud) => {
if (!cancelled) {
setState({ status: 'ready', cloud });
}
})
.catch(() => {
if (!cancelled) {
setState({ status: 'unavailable', cloud: null });
}
});
return () => {
cancelled = true;
};
}, [cloudId]);
return state;
}
+42
View File
@@ -0,0 +1,42 @@
import { useCallback, useState } from 'react';
import { FOCUS_CONFIG } from '../config/focus';
import type { FocusEvent } from '../types/channel';
import {
focusQueueReducer,
type FocusQueueState,
} from './focusQueue';
export function useFocusQueue(maxSize: number = FOCUS_CONFIG.queueSize) {
const [state, setState] = useState<FocusQueueState>({
current: null,
pending: [],
});
const enqueue = useCallback(
(event: FocusEvent) => {
setState((currentState) =>
focusQueueReducer(currentState, { type: 'ENQUEUE', event, maxSize }),
);
},
[maxSize],
);
const advance = useCallback(() => {
setState((currentState) =>
focusQueueReducer(currentState, { type: 'ADVANCE' }),
);
}, []);
const clear = useCallback(() => {
setState((currentState) =>
focusQueueReducer(currentState, { type: 'CLEAR' }),
);
}, []);
return {
...state,
enqueue,
advance,
clear,
};
}
+73
View File
@@ -0,0 +1,73 @@
import { useCallback, useEffect, useRef } from 'react';
import { FOCUS_CONFIG } from '../config/focus';
import { createBroadcastRealtimeChannel } from '../realtime/broadcastRealtimeChannel';
import {
buildRealtimeWebSocketUrl,
resolveRealtimeMode,
} from '../realtime/realtimeConfig';
import { createWebSocketRealtimeChannel } from '../realtime/websocketRealtimeChannel';
import type { FocusEvent, RealtimeChannel } from '../types/channel';
function createMessageId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
export function useWordCloudChannel(
cloudId: string,
onEvent: (message: FocusEvent) => void,
) {
const channelRef = useRef<RealtimeChannel | null>(null);
const onEventRef = useRef(onEvent);
useEffect(() => {
onEventRef.current = onEvent;
});
useEffect(() => {
const mode = resolveRealtimeMode(
import.meta.env.VITE_REALTIME_MODE,
import.meta.env.PROD,
);
const channel =
mode === 'websocket'
? createWebSocketRealtimeChannel(
buildRealtimeWebSocketUrl(import.meta.env.VITE_REALTIME_WS_URL),
)
: createBroadcastRealtimeChannel(FOCUS_CONFIG.channelName);
channelRef.current = channel;
const unsubscribe = channel.subscribe((message) => {
if (message.cloudId === cloudId) {
onEventRef.current(message);
}
});
return () => {
unsubscribe();
channel.close();
channelRef.current = null;
};
}, [cloudId]);
const send = useCallback(
(name: string, targetIndex = 0) => {
const message: FocusEvent = {
type: 'FOCUS_NAME',
cloudId,
name,
targetIndex,
messageId: createMessageId(),
sentAt: Date.now(),
};
channelRef.current?.publish(message);
},
[cloudId],
);
return send;
}
+351
View File
@@ -0,0 +1,351 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type {
KeyboardEvent as ReactKeyboardEvent,
PointerEvent as ReactPointerEvent,
WheelEvent as ReactWheelEvent,
} from 'react';
import { FOCUS_CONFIG } from '../config/focus';
import {
buildFocusTargets,
calculateFocusTransform,
clamp,
type FocusTarget,
} from '../lib/wordCloud';
import type { WordCloudWord } from '../types/cloud';
import type { FocusTransform } from '../lib/wordCloud';
export type FocusPhase = 'idle' | 'focusing' | 'holding' | 'returning';
export interface CloudSearchResult {
query: string;
targets: FocusTarget[];
selectedTargetIndex: number;
}
const IDENTITY_TRANSFORM: FocusTransform = { x: 0, y: 0, scale: 1 };
interface UseWordCloudFocusOptions {
words: WordCloudWord[];
autoReturn?: boolean;
onComplete?: () => void;
}
interface PointerInteraction {
pointers: Map<number, { x: number; y: number }>;
initialTransform?: FocusTransform;
initialDistance?: number;
initialMidpoint?: { x: number; y: number };
}
export function useWordCloudFocus({
words,
autoReturn = false,
onComplete,
}: UseWordCloudFocusOptions) {
const cloudRef = useRef<HTMLDivElement | null>(null);
const [viewport, setViewport] = useState({ width: 0, height: 0 });
const [phase, setPhase] = useState<FocusPhase>('idle');
const [transform, setTransform] = useState<FocusTransform>(IDENTITY_TRANSFORM);
const [activeIds, setActiveIds] = useState<Set<string>>(new Set());
const [searchResult, setSearchResult] = useState<CloudSearchResult | null>(null);
const timersRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());
const interactionRef = useRef<PointerInteraction>({ pointers: new Map() });
const onCompleteRef = useRef(onComplete);
const activeTarget = searchResult?.targets[searchResult.selectedTargetIndex] ?? null;
useEffect(() => {
onCompleteRef.current = onComplete;
});
const clearTimers = useCallback(() => {
timersRef.current.forEach((timer) => clearTimeout(timer));
timersRef.current.clear();
}, []);
useEffect(() => {
const cloud = cloudRef.current;
if (!cloud) {
return;
}
const updateViewport = () => {
const bounds = cloud.getBoundingClientRect();
setViewport({ width: bounds.width, height: bounds.height });
};
updateViewport();
if (typeof ResizeObserver === 'undefined') {
return;
}
const observer = new ResizeObserver(updateViewport);
observer.observe(cloud);
return () => observer.disconnect();
}, [words.length]);
useEffect(() => {
if (
!activeTarget
|| (phase !== 'focusing' && phase !== 'holding')
|| viewport.width <= 0
|| viewport.height <= 0
) {
return;
}
setTransform(calculateFocusTransform(activeTarget, viewport));
}, [activeTarget, phase, viewport]);
useEffect(() => clearTimers, [clearTimers]);
const registerTimer = useCallback((callback: () => void, delay: number) => {
const timer = setTimeout(() => {
timersRef.current.delete(timer);
callback();
}, delay);
timersRef.current.add(timer);
}, []);
const startFocus = useCallback(
(target: FocusTarget, query: string, targets: FocusTarget[], targetIndex: number) => {
const bounds = cloudRef.current?.getBoundingClientRect();
const currentViewport = {
width: bounds?.width || viewport.width || window.innerWidth,
height: bounds?.height || viewport.height || window.innerHeight,
};
clearTimers();
setSearchResult({
query,
targets,
selectedTargetIndex: targetIndex,
});
setActiveIds(new Set(target.words.map((word) => word.id)));
setTransform(calculateFocusTransform(target, currentViewport));
setPhase('focusing');
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const focusDuration = reducedMotion ? 0 : FOCUS_CONFIG.focusDuration;
const holdDuration = reducedMotion ? 0 : FOCUS_CONFIG.holdDuration;
const returnDuration = reducedMotion ? 0 : FOCUS_CONFIG.returnDuration;
registerTimer(() => setPhase('holding'), focusDuration);
if (autoReturn) {
registerTimer(() => {
setPhase('returning');
setTransform(IDENTITY_TRANSFORM);
}, focusDuration + holdDuration);
registerTimer(() => {
setPhase('idle');
setActiveIds(new Set());
setSearchResult(null);
onCompleteRef.current?.();
}, focusDuration + holdDuration + returnDuration);
}
},
[autoReturn, clearTimers, registerTimer, viewport.height, viewport.width],
);
const focusName = useCallback(
(query: string, requestedTargetIndex = 0) => {
const targets = buildFocusTargets(words, query);
if (targets.length === 0) {
return false;
}
const targetIndex = Math.min(
Math.max(0, Math.floor(requestedTargetIndex)),
targets.length - 1,
);
startFocus(targets[targetIndex], query, targets, targetIndex);
return true;
},
[startFocus, words],
);
const selectTarget = useCallback(
(targetIndex: number) => {
if (!searchResult || targetIndex < 0 || targetIndex >= searchResult.targets.length) {
return;
}
const target = searchResult.targets[targetIndex];
startFocus(target, searchResult.query, searchResult.targets, targetIndex);
},
[searchResult, startFocus],
);
const reset = useCallback(() => {
clearTimers();
setPhase('idle');
setTransform(IDENTITY_TRANSFORM);
setActiveIds(new Set());
setSearchResult(null);
}, [clearTimers]);
const getPointerPosition = (event: PointerEvent | WheelEvent) => {
const bounds = cloudRef.current?.getBoundingClientRect();
return {
x: event.clientX - (bounds?.left || 0),
y: event.clientY - (bounds?.top || 0),
};
};
const handlePointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
if (phase !== 'idle') {
return;
}
const position = getPointerPosition(event.nativeEvent);
const interaction = interactionRef.current;
interaction.pointers.set(event.pointerId, position);
event.currentTarget.setPointerCapture(event.pointerId);
if (interaction.pointers.size === 2) {
const [first, second] = [...interaction.pointers.values()];
interaction.initialTransform = transform;
interaction.initialDistance = Math.hypot(second.x - first.x, second.y - first.y);
interaction.initialMidpoint = {
x: (first.x + second.x) / 2,
y: (first.y + second.y) / 2,
};
}
}, [phase, transform]);
const handlePointerMove = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
const interaction = interactionRef.current;
if (phase !== 'idle' || !interaction.pointers.has(event.pointerId)) {
return;
}
const position = getPointerPosition(event.nativeEvent);
const previousPosition = interaction.pointers.get(event.pointerId);
interaction.pointers.set(event.pointerId, position);
if (interaction.pointers.size === 1 && previousPosition) {
setTransform((current) => ({
...current,
x: current.x + position.x - previousPosition.x,
y: current.y + position.y - previousPosition.y,
}));
}
if (interaction.pointers.size === 2 && interaction.initialTransform) {
const [first, second] = [...interaction.pointers.values()];
const distance = Math.hypot(second.x - first.x, second.y - first.y);
const initialDistance = interaction.initialDistance || distance;
const initialMidpoint = interaction.initialMidpoint || { x: 0, y: 0 };
const nextScale = clamp(
interaction.initialTransform.scale * (distance / initialDistance),
1,
FOCUS_CONFIG.manualMaxScale,
);
const zoomRatio = nextScale / interaction.initialTransform.scale;
setTransform({
scale: nextScale,
x: initialMidpoint.x - (initialMidpoint.x - interaction.initialTransform.x) * zoomRatio,
y: initialMidpoint.y - (initialMidpoint.y - interaction.initialTransform.y) * zoomRatio,
});
}
}, [phase]);
const handlePointerUp = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
interactionRef.current.pointers.delete(event.pointerId);
if (interactionRef.current.pointers.size < 2) {
interactionRef.current.initialTransform = undefined;
interactionRef.current.initialDistance = undefined;
interactionRef.current.initialMidpoint = undefined;
}
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
}, []);
const handleWheel = useCallback((event: ReactWheelEvent<HTMLDivElement>) => {
if (phase !== 'idle') {
return;
}
event.preventDefault();
const pointer = getPointerPosition(event.nativeEvent);
setTransform((current) => {
const factor = event.deltaY < 0 ? 1.08 : 1 / 1.08;
const nextScale = clamp(
current.scale * factor,
1,
FOCUS_CONFIG.manualMaxScale,
);
const zoomRatio = nextScale / current.scale;
return {
scale: nextScale,
x: pointer.x - (pointer.x - current.x) * zoomRatio,
y: pointer.y - (pointer.y - current.y) * zoomRatio,
};
});
}, [phase]);
const handleKeyDown = useCallback((event: ReactKeyboardEvent<HTMLDivElement>) => {
if (phase !== 'idle') {
return;
}
const keyActions: Record<string, () => void> = {
ArrowLeft: () => setTransform((current) => ({ ...current, x: current.x + FOCUS_CONFIG.panStep })),
ArrowRight: () => setTransform((current) => ({ ...current, x: current.x - FOCUS_CONFIG.panStep })),
ArrowUp: () => setTransform((current) => ({ ...current, y: current.y + FOCUS_CONFIG.panStep })),
ArrowDown: () => setTransform((current) => ({ ...current, y: current.y - FOCUS_CONFIG.panStep })),
'+': () => setTransform((current) => ({
...current,
scale: clamp(current.scale * FOCUS_CONFIG.zoomStep, 1, FOCUS_CONFIG.manualMaxScale),
})),
'=': () => setTransform((current) => ({
...current,
scale: clamp(current.scale * FOCUS_CONFIG.zoomStep, 1, FOCUS_CONFIG.manualMaxScale),
})),
'-': () => setTransform((current) => ({
...current,
scale: clamp(current.scale / FOCUS_CONFIG.zoomStep, 1, FOCUS_CONFIG.manualMaxScale),
})),
};
const action = keyActions[event.key];
if (action) {
event.preventDefault();
action();
}
}, [phase]);
return {
cloudRef,
viewport,
phase,
transform,
activeIds,
activeTarget,
searchResult,
focusName,
focusTarget: startFocus,
selectTarget,
reset,
handlePointerDown,
handlePointerMove,
handlePointerUp,
handleWheel,
handleKeyDown,
};
}
+80
View File
@@ -0,0 +1,80 @@
import {
buildFocusTargets,
calculateFocusTransform,
normalizeName,
} from '../wordCloud';
import type { WordCloudWord } from '../../types/cloud';
const word = (
id: string,
name: string,
x: number,
y: number,
): WordCloudWord => ({ id, name, x, y, fontSize: 28, tier: 3 });
describe('word cloud search', () => {
it('normalizes trimmed English names case-insensitively', () => {
expect(normalizeName(' Sophia ')).toBe('sophia');
expect(normalizeName('SOPHIA')).toBe(normalizeName('sophia'));
});
it('keeps Chinese names stable', () => {
expect(normalizeName(' 王小明 ')).toBe('王小明');
});
it('returns no targets for an unknown name', () => {
const targets = buildFocusTargets([word('a', '王小明', 50, 44)], '不存在');
expect(targets).toHaveLength(0);
});
it('groups close duplicate matches into one target', () => {
const targets = buildFocusTargets(
[
word('sophia-1', 'Sophia', 68, 30),
word('sophia-2', 'Sophia', 58, 38),
],
'sophia',
);
expect(targets).toHaveLength(1);
expect(targets[0].words.map(({ id }) => id)).toEqual(['sophia-1', 'sophia-2']);
expect(targets[0].center).toEqual({ x: 63, y: 34 });
});
it('keeps distant duplicate matches as separate paginated targets', () => {
const targets = buildFocusTargets(
[
word('alex-1', 'Alex', 38, 52),
word('alex-2', 'Alex', 18, 20),
],
'Alex',
);
expect(targets).toHaveLength(2);
expect(targets[0].words[0].id).toBe('alex-1');
expect(targets[1].words[0].id).toBe('alex-2');
});
});
describe('focus camera calculation', () => {
it('moves the requested point to the viewport center and clamps zoom', () => {
const target = buildFocusTargets(
[
word('target-a', 'Sophia', 68, 30),
word('target-b', 'Sophia', 58, 38),
],
'Sophia',
)[0];
const transform = calculateFocusTransform(target, { width: 1200, height: 800 });
expect(transform.scale).toBeGreaterThan(1);
expect(transform.scale).toBeLessThanOrEqual(2.5);
const screenX = transform.x + (target.center.x / 100) * 1200 * transform.scale;
const screenY = transform.y + (target.center.y / 100) * 800 * transform.scale;
expect(screenX).toBeCloseTo(600);
expect(screenY).toBeCloseTo(400);
});
});
+64
View File
@@ -0,0 +1,64 @@
import { mapImportedWordLocations } from '../wordcloudImport';
import type { ImportedWordLocationResult } from '../wordcloudImport';
const result: ImportedWordLocationResult = {
job_id: 'job-1',
query: '',
mode: 'exact',
total: 2,
canvas_width: 1000,
canvas_height: 2000,
matches: [
{
id: 1,
name: '王小明',
x: 100,
y: 200,
font_size: 100,
color: '#000000',
orientation: 'horizontal',
box_x: 100,
box_y: 200,
box_width: 100,
box_height: 50,
},
{
id: 2,
name: '李欣',
x: 700,
y: 1200,
font_size: 25,
color: '#000000',
orientation: 'vertical',
box_x: 700,
box_y: 1200,
box_width: 60,
box_height: 200,
},
],
};
describe('imported word locations', () => {
it('maps database pixels to responsive percentage coordinates', () => {
const words = mapImportedWordLocations(result, {
jobId: 'job-1',
eyebrow: '',
title: '',
subtitle: '',
});
expect(words).toHaveLength(2);
expect(words[0].x).toBeCloseTo(15);
expect(words[0].y).toBeCloseTo(11.25);
expect(words[0].fontSize).toBe(100);
expect(words[0]).toMatchObject({
sourceBox: { width: 100, height: 50 },
});
expect(words[0].tier).toBe(4);
expect(words[1].x).toBeCloseTo(73);
expect(words[1].y).toBeCloseTo(65);
expect(words[1].fontSize).toBe(25);
expect(words[1].tier).toBe(1);
expect(words[1].rotation).toBe(90);
});
});
+44
View File
@@ -0,0 +1,44 @@
import type { WordCloudWord } from '../types/cloud';
function countRenderedGlyphs(name: string): number {
return Array.from(name).filter((character) => !/\s/u.test(character)).length;
}
export function groupSvgPathsByWord(
svgMarkup: string,
words: WordCloudWord[],
): string {
const document = new DOMParser().parseFromString(svgMarkup, 'image/svg+xml');
const root = document.documentElement;
if (root.localName !== 'svg' || document.querySelector('parsererror')) {
throw new Error('The source SVG could not be parsed.');
}
const paths = Array.from(root.children).filter((element) => element.localName === 'path');
const glyphPathCount = words.reduce(
(total, word) => total + countRenderedGlyphs(word.name),
0,
);
const pathsPerWord = paths.length === words.length ? 1 : 0;
if (paths.length !== glyphPathCount && pathsPerWord === 0) {
throw new Error('The source SVG paths do not match the imported word list.');
}
let pathIndex = 0;
for (const word of words) {
const pathCount = pathsPerWord || countRenderedGlyphs(word.name);
const group = document.createElementNS(root.namespaceURI, 'g');
group.setAttribute('data-word-id', word.id);
group.setAttribute('data-active', 'false');
root.insertBefore(group, paths[pathIndex]);
for (let glyphIndex = 0; glyphIndex < pathCount; glyphIndex += 1) {
group.append(paths[pathIndex]);
pathIndex += 1;
}
}
return new XMLSerializer().serializeToString(root);
}
+138
View File
@@ -0,0 +1,138 @@
import { FOCUS_CONFIG } from '../config/focus';
import type { WordCloudWord } from '../types/cloud';
export interface FocusBounds {
minX: number;
minY: number;
maxX: number;
maxY: number;
}
export interface FocusTarget {
id: string;
words: WordCloudWord[];
center: { x: number; y: number };
bounds: FocusBounds;
}
export interface FocusTransform {
x: number;
y: number;
scale: number;
}
export function normalizeName(value: string): string {
return value.trim().replace(/\s+/g, ' ').toLowerCase();
}
export function findWordsByName(
words: WordCloudWord[],
query: string,
): WordCloudWord[] {
const normalized = normalizeName(query);
if (!normalized) {
return [];
}
return words.filter((word) => normalizeName(word.name) === normalized);
}
export function buildFocusTargets(
words: WordCloudWord[],
query: string,
): FocusTarget[] {
const matches = findWordsByName(words, query);
const targets: FocusTarget[] = [];
const visited = new Set<string>();
for (let index = 0; index < matches.length; index += 1) {
const current = matches[index];
if (visited.has(current.id)) {
continue;
}
const group = [current];
visited.add(current.id);
for (let nextIndex = index + 1; nextIndex < matches.length; nextIndex += 1) {
const candidate = matches[nextIndex];
if (visited.has(candidate.id)) {
continue;
}
const groupMinX = Math.min(...group.map((word) => word.x));
const groupMaxX = Math.max(...group.map((word) => word.x));
const groupMinY = Math.min(...group.map((word) => word.y));
const groupMaxY = Math.max(...group.map((word) => word.y));
const isNearby =
candidate.x >= groupMinX - FOCUS_CONFIG.nearbyThreshold &&
candidate.x <= groupMaxX + FOCUS_CONFIG.nearbyThreshold &&
candidate.y >= groupMinY - FOCUS_CONFIG.nearbyThreshold &&
candidate.y <= groupMaxY + FOCUS_CONFIG.nearbyThreshold;
if (!isNearby) {
continue;
}
group.push(candidate);
visited.add(candidate.id);
}
targets.push(createFocusTarget(group));
}
return targets;
}
export function createFocusTarget(words: WordCloudWord[]): FocusTarget {
const xValues = words.map((word) => word.x);
const yValues = words.map((word) => word.y);
const minX = Math.min(...xValues);
const maxX = Math.max(...xValues);
const minY = Math.min(...yValues);
const maxY = Math.max(...yValues);
return {
id: `target-${words.map((word) => word.id).join('-')}`,
words,
center: {
x: (minX + maxX) / 2,
y: (minY + maxY) / 2,
},
bounds: { minX, minY, maxX, maxY },
};
}
export function calculateFocusTransform(
target: FocusTarget,
viewport: { width: number; height: number },
): FocusTransform {
const boundsWidth = target.bounds.maxX - target.bounds.minX;
const boundsHeight = target.bounds.maxY - target.bounds.minY;
const contentWidth =
(boundsWidth / 100) * viewport.width + FOCUS_CONFIG.targetPadding;
const contentHeight =
(boundsHeight / 100) * viewport.height + FOCUS_CONFIG.targetPadding;
const scale = clamp(
Math.min(
FOCUS_CONFIG.maxScale,
viewport.width / contentWidth,
viewport.height / contentHeight,
),
FOCUS_CONFIG.minScale,
FOCUS_CONFIG.maxScale,
);
return {
x: viewport.width / 2 - (target.center.x / 100) * viewport.width * scale,
y: viewport.height / 2 - (target.center.y / 100) * viewport.height * scale,
scale,
};
}
export function clamp(value: number, minimum: number, maximum: number): number {
return Math.min(Math.max(value, minimum), maximum);
}
+74
View File
@@ -0,0 +1,74 @@
import {
isImportedWordLocationResult,
mapImportedWordLocations,
} from './wordcloudImport';
import {
resolveWordCloudJobId,
wordCloudApiUrl,
} from '../config/wordcloudApi';
import type { WordCloud } from '../types/cloud';
const REMOTE_PRESENTATION = {
eyebrow: 'LIVE ARCHIVE · 2026',
title: '2026 毕业典礼 · 全体名单',
subtitle: 'CLASS OF 2026 · WORD CLOUD',
caption: 'REUSED FROM WORDCLOUD DATABASE',
theme: {
background: '#ffffff',
foreground: '#211922',
accent: '#e60023',
},
} satisfies Omit<WordCloud, 'id' | 'words' | 'sourceCanvas' | 'sourceSvg'>;
async function getJson(path: string): Promise<unknown> {
let response: Response;
try {
response = await fetch(wordCloudApiUrl(path));
} catch {
throw new Error('无法连接词云数据服务');
}
if (!response.ok) {
throw new Error(`词云数据服务返回 ${response.status}`);
}
try {
return await response.json();
} catch {
throw new Error('词云数据服务返回了无效数据');
}
}
export async function loadRemoteWordCloud(cloudId: string): Promise<WordCloud> {
const jobId = resolveWordCloudJobId(cloudId);
const locations = await getJson(
`/api/jobs/${encodeURIComponent(jobId)}/locations?name=`,
);
if (
!isImportedWordLocationResult(locations)
|| locations.job_id !== jobId
|| locations.matches.length === 0
) {
throw new Error('词云任务没有可用的位置数据');
}
return {
id: cloudId,
...REMOTE_PRESENTATION,
sourceCanvas: {
width: locations.canvas_width,
height: locations.canvas_height,
},
sourceSvg: wordCloudApiUrl(
`/api/jobs/${encodeURIComponent(jobId)}/files/svg`,
),
words: mapImportedWordLocations(locations, {
jobId,
eyebrow: REMOTE_PRESENTATION.eyebrow,
title: REMOTE_PRESENTATION.title,
subtitle: REMOTE_PRESENTATION.subtitle,
}),
};
}
+137
View File
@@ -0,0 +1,137 @@
import type { WordCloudWord, WordTier } from '../types/cloud';
export interface ImportedWordLocation {
id: number;
name: string;
x: number;
y: number;
font_size: number;
color: string;
orientation: string;
box_x: number;
box_y: number;
box_width: number;
box_height: number;
}
export interface ImportedWordLocationResult {
job_id: string;
query: string;
mode: 'exact' | 'contains';
total: number;
canvas_width: number;
canvas_height: number;
matches: ImportedWordLocation[];
}
export interface ImportedWordCloudMetadata {
jobId: string;
title: string;
subtitle: string;
eyebrow: string;
caption?: string;
}
const FONT_TIERS: ReadonlyArray<{ minimumRatio: number; tier: WordTier }> = [
{ minimumRatio: 0.74, tier: 4 },
{ minimumRatio: 0.52, tier: 3 },
{ minimumRatio: 0.28, tier: 2 },
{ minimumRatio: 0, tier: 1 },
];
export function isImportedWordLocationResult(value: unknown): value is ImportedWordLocationResult {
if (typeof value !== 'object' || value === null) {
return false;
}
const result = value as Partial<ImportedWordLocationResult>;
return (
typeof result.job_id === 'string' &&
(result.mode === 'exact' || result.mode === 'contains') &&
typeof result.total === 'number' &&
typeof result.canvas_width === 'number' &&
typeof result.canvas_height === 'number' &&
Array.isArray(result.matches)
);
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
function normalizeMatch(
value: unknown,
maxHeightFontSize: number,
): ImportedWordLocation | null {
if (typeof value !== 'object' || value === null) {
return null;
}
const match = value as Partial<ImportedWordLocation>;
if (
typeof match.name !== 'string' ||
!isFiniteNumber(match.id) ||
!isFiniteNumber(match.x) ||
!isFiniteNumber(match.y) ||
!isFiniteNumber(match.font_size) ||
!isFiniteNumber(match.box_x) ||
!isFiniteNumber(match.box_y) ||
!isFiniteNumber(match.box_width) ||
!isFiniteNumber(match.box_height) ||
!isFiniteNumber(maxHeightFontSize) ||
maxHeightFontSize <= 0
) {
return null;
}
return {
id: match.id,
name: match.name,
x: match.x,
y: match.y,
font_size: match.font_size,
color: typeof match.color === 'string' ? match.color : '',
orientation: typeof match.orientation === 'string' ? match.orientation : 'horizontal',
box_x: match.box_x,
box_y: match.box_y,
box_width: match.box_width,
box_height: match.box_height,
};
}
function toTier(fontSize: number, maximumFontSize: number): WordTier {
const ratio = fontSize / maximumFontSize;
return FONT_TIERS.find((entry) => ratio >= entry.minimumRatio)?.tier ?? 1;
}
export function mapImportedWordLocations(
result: ImportedWordLocationResult,
metadata: ImportedWordCloudMetadata,
): WordCloudWord[] {
const maximumFontSize = Math.max(
...result.matches.map((match) => match.font_size),
);
return result.matches
.map((match) => normalizeMatch(match, maximumFontSize))
.filter((match): match is ImportedWordLocation => match !== null)
.map((match) => {
const hasBox = match.box_width > 0 && match.box_height > 0;
const centerX = hasBox ? match.box_x + match.box_width / 2 : match.x;
const centerY = hasBox ? match.box_y + match.box_height / 2 : match.y;
return {
id: `${metadata.jobId}-${match.id}`,
name: match.name,
x: (centerX / result.canvas_width) * 100,
y: (centerY / result.canvas_height) * 100,
fontSize: match.font_size,
sourceBox: hasBox
? { width: match.box_width, height: match.box_height }
: undefined,
rotation: match.orientation.trim().toLowerCase() === 'vertical' ? 90 : undefined,
tier: toTier(match.font_size, maximumFontSize),
} satisfies WordCloudWord;
});
}
+13
View File
@@ -0,0 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import './styles/global.css';
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
);
+23
View File
@@ -0,0 +1,23 @@
.page {
position: relative;
height: 100dvh;
overflow: hidden;
background: var(--background);
--cloud-scale: 1;
}
@media (min-width: 768px) {
.page {
--cloud-scale: 1.15;
}
}
.page[data-cloud='imported'] {
--cloud-scale: 0.12;
}
@media (min-width: 768px) {
.page[data-cloud='imported'] {
--cloud-scale: 0.25;
}
}
+90
View File
@@ -0,0 +1,90 @@
import { useState } from 'react';
import type { FormEvent } from 'react';
import { CloudHeader } from '../components/CloudHeader';
import { FloatingSearchDock } from '../components/FloatingSearchDock';
import { WordCloudCanvas } from '../components/WordCloudCanvas';
import { CloudLoadingState } from '../components/CloudLoadingState';
import { CloudUnavailableState } from '../components/CloudUnavailableState';
import { TitleFrost } from '../components/TitleFrost';
import { useCloud } from '../hooks/useCloud';
import { useWordCloudFocus } from '../hooks/useWordCloudFocus';
import styles from './PersonalSearchPage.module.css';
interface PersonalSearchPageProps {
cloudId: string;
}
export function PersonalSearchPage({ cloudId }: PersonalSearchPageProps) {
const cloudState = useCloud(cloudId);
const cloud = cloudState.cloud;
const [query, setQuery] = useState('');
const [hasNotFound, setHasNotFound] = useState(false);
const focus = useWordCloudFocus({ words: cloud?.words ?? [] });
if (cloudState.status === 'loading') {
return <CloudLoadingState />;
}
if (!cloud) {
return <CloudUnavailableState />;
}
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!query.trim()) {
setHasNotFound(false);
return;
}
const found = focus.focusName(query);
setHasNotFound(!found);
};
const handleReset = () => {
focus.reset();
setQuery('');
setHasNotFound(false);
};
return (
<main className={styles.page} data-cloud={cloud.id} aria-label="个人查找词云">
<WordCloudCanvas
words={cloud.words}
phase={focus.phase}
transform={focus.transform}
activeIds={focus.activeIds}
cloudRef={focus.cloudRef}
onPointerDown={focus.handlePointerDown}
onPointerMove={focus.handlePointerMove}
onPointerUp={focus.handlePointerUp}
onWheel={focus.handleWheel}
onKeyDown={focus.handleKeyDown}
sourceCanvas={cloud.sourceCanvas}
sourceSvg={cloud.sourceSvg}
viewport={focus.viewport}
/>
<TitleFrost />
<CloudHeader
eyebrow={cloud.eyebrow}
title={cloud.title}
subtitle={cloud.subtitle}
dimmed={focus.phase !== 'idle'}
layered
/>
<FloatingSearchDock
name={query}
status={focus.searchResult ? 'found' : hasNotFound ? 'not-found' : 'idle'}
targetCount={focus.searchResult?.targets.length ?? 0}
selectedTargetIndex={focus.searchResult?.selectedTargetIndex ?? 0}
onChange={(name) => {
setQuery(name);
setHasNotFound(false);
}}
onSubmit={handleSubmit}
onReset={handleReset}
onSelectTarget={focus.selectTarget}
/>
</main>
);
}
+36
View File
@@ -0,0 +1,36 @@
.page {
position: relative;
height: 100dvh;
overflow: hidden;
background: var(--background);
--cloud-scale: 1;
}
@media (min-width: 768px) {
.page {
--cloud-scale: 1.15;
}
}
@media (min-width: 1200px) {
.page {
--cloud-scale: 1.35;
}
}
.caption {
position: absolute;
bottom: 48px;
left: 48px;
z-index: 3;
margin: 0;
color: var(--text-meta);
font-size: var(--text-sm);
letter-spacing: 0.12em;
}
@media (max-width: 760px) {
.caption {
display: none;
}
}
+88
View File
@@ -0,0 +1,88 @@
import { useCallback, useEffect } from 'react';
import { CloudHeader } from '../components/CloudHeader';
import { FullscreenButton } from '../components/FullscreenButton';
import { QRPanel } from '../components/QRPanel';
import { WordCloudCanvas } from '../components/WordCloudCanvas';
import { CloudLoadingState } from '../components/CloudLoadingState';
import { CloudUnavailableState } from '../components/CloudUnavailableState';
import { useFocusQueue } from '../hooks/useFocusQueue';
import { useCloud } from '../hooks/useCloud';
import { useWordCloudChannel } from '../hooks/useWordCloudChannel';
import { useWordCloudFocus } from '../hooks/useWordCloudFocus';
import { TitleFrost } from '../components/TitleFrost';
import type { FocusEvent } from '../types/channel';
import styles from './PublicDisplayPage.module.css';
interface PublicDisplayPageProps {
cloudId: string;
}
export function PublicDisplayPage({ cloudId }: PublicDisplayPageProps) {
const cloudState = useCloud(cloudId);
const cloud = cloudState.cloud;
const queue = useFocusQueue();
const focus = useWordCloudFocus({
words: cloud?.words ?? [],
autoReturn: true,
onComplete: queue.advance,
});
const receiveMessage = useCallback(
(message: FocusEvent) => {
queue.enqueue(message);
},
[queue],
);
useWordCloudChannel(cloudId, receiveMessage);
useEffect(() => {
if (!queue.current || focus.phase !== 'idle' || !cloud) {
return;
}
const found = focus.focusName(queue.current.name, queue.current.targetIndex ?? 0);
if (!found) {
queue.advance();
}
}, [cloud, focus, queue]);
if (cloudState.status === 'loading') {
return <CloudLoadingState />;
}
if (!cloud) {
return <CloudUnavailableState />;
}
return (
<main className={styles.page} aria-label="大屏展示词云">
<WordCloudCanvas
words={cloud.words}
phase={focus.phase}
transform={focus.transform}
activeIds={focus.activeIds}
cloudRef={focus.cloudRef}
onPointerDown={focus.handlePointerDown}
onPointerMove={focus.handlePointerMove}
onPointerUp={focus.handlePointerUp}
onWheel={focus.handleWheel}
onKeyDown={focus.handleKeyDown}
sourceCanvas={cloud.sourceCanvas}
sourceSvg={cloud.sourceSvg}
viewport={focus.viewport}
/>
<TitleFrost />
<CloudHeader
eyebrow={cloud.eyebrow}
title={cloud.title}
subtitle={cloud.subtitle}
dimmed={focus.phase !== 'idle'}
layered
/>
<p className={styles.caption}>{cloud.caption}</p>
<QRPanel cloudId={cloudId} />
<FullscreenButton />
</main>
);
}
+83
View File
@@ -0,0 +1,83 @@
.page {
min-height: 100dvh;
display: grid;
justify-items: center;
padding: max(48px, env(safe-area-inset-top)) 16px 32px;
background: var(--background);
}
.box {
display: grid;
gap: 16px;
align-content: start;
width: min(440px, 100%);
margin-top: 6vh;
}
.eyebrow {
margin: 0;
color: var(--text-meta);
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.14em;
}
.title {
margin: 0;
color: var(--text-primary);
font-size: var(--text-2xl);
letter-spacing: var(--tracking-display);
}
.lede {
margin: 0;
color: var(--text-secondary);
}
.field {
display: grid;
gap: 8px;
margin-top: 24px;
}
.field label {
color: var(--text-primary);
font-size: var(--text-sm);
font-weight: 700;
}
.nameInput {
min-height: 48px;
padding: 12px 15px;
color: var(--text-primary);
background: var(--background);
border: 1px solid var(--text-meta);
border-radius: var(--radius-md);
}
.nameInput::placeholder {
color: var(--text-meta);
}
.mainButton {
min-height: 48px;
padding: 14px 20px;
color: var(--text-strong);
background: var(--accent);
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 700;
text-align: center;
}
.notFound {
color: var(--text-secondary);
font-size: var(--text-sm);
}
.foot {
margin: 0;
color: var(--text-meta);
font-size: var(--text-xs);
text-align: center;
}
+105
View File
@@ -0,0 +1,105 @@
import { useState } from 'react';
import type { FormEvent } from 'react';
import { ControllerSuccessState } from '../components/ControllerSuccessState';
import { CloudLoadingState } from '../components/CloudLoadingState';
import { CloudUnavailableState } from '../components/CloudUnavailableState';
import { useCloud } from '../hooks/useCloud';
import { useWordCloudChannel } from '../hooks/useWordCloudChannel';
import { buildFocusTargets, type FocusTarget } from '../lib/wordCloud';
import styles from './RemoteControllerPage.module.css';
interface RemoteControllerPageProps {
cloudId: string;
}
export function RemoteControllerPage({ cloudId }: RemoteControllerPageProps) {
const cloudState = useCloud(cloudId);
const cloud = cloudState.cloud;
const [name, setName] = useState('');
const [status, setStatus] = useState<'idle' | 'not-found' | 'success'>('idle');
const [controllerTargets, setControllerTargets] = useState<FocusTarget[]>([]);
const [selectedTargetIndex, setSelectedTargetIndex] = useState(0);
const sendFocusEvent = useWordCloudChannel(cloudId, () => undefined);
if (cloudState.status === 'loading') {
return <CloudLoadingState />;
}
if (!cloud) {
return <CloudUnavailableState />;
}
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const normalizedName = name.trim();
if (!normalizedName) {
setStatus('idle');
return;
}
const targets = buildFocusTargets(cloud.words, normalizedName);
if (targets.length === 0) {
setControllerTargets([]);
setStatus('not-found');
return;
}
setControllerTargets(targets);
setSelectedTargetIndex(0);
sendFocusEvent(normalizedName, 0);
setStatus('success');
};
return (
<main className={styles.page} aria-label="遥控器">
<div className={styles.box}>
<p className={styles.eyebrow}>LIVE · </p>
<h1 className={styles.title}></h1>
<p className={styles.lede}></p>
<form className={styles.field} onSubmit={handleSubmit}>
<label htmlFor="controller-name"></label>
<input
id="controller-name"
className={styles.nameInput}
type="text"
name="name"
autoComplete="off"
placeholder="输入你的名字"
value={name}
onChange={(event) => {
setName(event.target.value);
setStatus('idle');
}}
/>
<button className={styles.mainButton} type="submit">
</button>
</form>
{status === 'not-found' ? (
<p className={styles.notFound} aria-live="polite">
</p>
) : null}
{status === 'success' ? (
<ControllerSuccessState
targetCount={controllerTargets.length}
selectedTargetIndex={selectedTargetIndex}
onSelectTarget={(targetIndex) => {
const target = controllerTargets[targetIndex];
if (!target) {
return;
}
setSelectedTargetIndex(targetIndex);
sendFocusEvent(name.trim(), targetIndex);
}}
/>
) : null}
<p className={styles.foot}></p>
</div>
</main>
);
}
@@ -0,0 +1,27 @@
import { createBroadcastRealtimeChannel } from '../broadcastRealtimeChannel';
import type { FocusEvent } from '../../types/channel';
const event: FocusEvent = {
type: 'FOCUS_NAME',
cloudId: 'demo',
name: '王小明',
targetIndex: 1,
messageId: 'focus-1',
sentAt: 123,
};
describe('BroadcastChannel realtime adapter', () => {
it('publishes an event from the controller to a separate screen channel', async () => {
const controller = createBroadcastRealtimeChannel('wordcloud-test');
const screen = createBroadcastRealtimeChannel('wordcloud-test');
const received: FocusEvent[] = [];
screen.subscribe((message) => received.push(message));
controller.publish(event);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(received).toEqual([event]);
controller.close();
screen.close();
});
});
@@ -0,0 +1,41 @@
import {
buildRealtimeWebSocketUrl,
resolveRealtimeMode,
} from '../realtimeConfig';
describe('realtime configuration', () => {
it('uses BroadcastChannel for local development by default', () => {
expect(resolveRealtimeMode(undefined, false)).toBe('broadcast');
});
it('uses WebSocket for production by default', () => {
expect(resolveRealtimeMode(undefined, true)).toBe('websocket');
});
it('honors an explicit realtime mode override', () => {
expect(resolveRealtimeMode('broadcast', true)).toBe('broadcast');
expect(resolveRealtimeMode('websocket', false)).toBe('websocket');
});
it('builds a same-origin WebSocket URL behind any deployed hostname', () => {
expect(buildRealtimeWebSocketUrl()).toBe('ws://localhost:3000/ws');
});
it('builds a secure WebSocket URL for HTTPS deployments', () => {
expect(
buildRealtimeWebSocketUrl(
'/ws',
'https://screen.example.com',
),
).toBe('wss://screen.example.com/ws');
});
it('uses an explicit absolute WebSocket URL when provided', () => {
expect(
buildRealtimeWebSocketUrl(
'wss://realtime.example.com/socket',
'https://screen.example.com',
),
).toBe('wss://realtime.example.com/socket');
});
});
@@ -0,0 +1,64 @@
import { createWebSocketRealtimeChannel } from '../websocketRealtimeChannel';
import type { FocusEvent } from '../../types/channel';
class FakeWebSocket {
static instances: FakeWebSocket[] = [];
sent: unknown[] = [];
closed = false;
onmessage: ((event: MessageEvent) => void) | null = null;
constructor(public url: string) {
FakeWebSocket.instances.push(this);
}
send(message: unknown) {
this.sent.push(message);
}
close() {
this.closed = true;
}
}
const event: FocusEvent = {
type: 'FOCUS_NAME',
cloudId: 'demo',
name: 'Sophia',
targetIndex: 1,
messageId: 'focus-1',
sentAt: 1,
};
describe('WebSocket realtime channel', () => {
beforeEach(() => {
FakeWebSocket.instances = [];
});
it('publishes valid focus events', () => {
const received: FocusEvent[] = [];
const channel = createWebSocketRealtimeChannel('ws://realtime/ws', FakeWebSocket);
channel.subscribe((message) => received.push(message));
channel.publish(event);
const socket = FakeWebSocket.instances[0];
socket.onmessage?.({ data: socket.sent[0] } as MessageEvent);
expect(socket.sent).toEqual([JSON.stringify(event)]);
expect(received).toEqual([event]);
channel.close();
});
it('ignores malformed server messages and closes cleanly', () => {
const received: FocusEvent[] = [];
const channel = createWebSocketRealtimeChannel('ws://realtime/ws', FakeWebSocket);
channel.subscribe((message) => received.push(message));
const socket = FakeWebSocket.instances[0];
socket.onmessage?.({ data: { type: 'NOISE' } } as MessageEvent);
expect(received).toEqual([]);
channel.close();
expect(socket.closed).toBe(true);
});
});
+36
View File
@@ -0,0 +1,36 @@
import type {
FocusEvent,
RealtimeChannel,
RealtimeListener,
} from '../types/channel';
import { isFocusEvent } from './focusEventGuard';
export function createBroadcastRealtimeChannel(
channelName: string,
): RealtimeChannel {
const channel = new BroadcastChannel(channelName);
return {
publish(message: FocusEvent) {
channel.postMessage(message);
},
subscribe(listener: RealtimeListener) {
const handleMessage = (event: MessageEvent) => {
const data: unknown = event.data;
if (isFocusEvent(data)) {
listener(data);
}
};
channel.addEventListener('message', handleMessage);
return () => {
channel.removeEventListener('message', handleMessage);
};
},
close() {
channel.close();
},
};
}
+21
View File
@@ -0,0 +1,21 @@
import type { FocusEvent } from '../types/channel';
export function isFocusEvent(value: unknown): value is FocusEvent {
if (typeof value !== 'object' || value === null) {
return false;
}
const event = value as Partial<FocusEvent>;
return (
event.type === 'FOCUS_NAME' &&
typeof event.cloudId === 'string' &&
typeof event.name === 'string' &&
typeof event.messageId === 'string' &&
typeof event.sentAt === 'number' &&
(event.targetIndex === undefined ||
(typeof event.targetIndex === 'number' &&
Number.isInteger(event.targetIndex) &&
event.targetIndex >= 0))
);
}
+30
View File
@@ -0,0 +1,30 @@
export type RealtimeMode = 'broadcast' | 'websocket';
export function resolveRealtimeMode(
configuredMode: string | undefined,
isProduction: boolean,
): RealtimeMode {
if (configuredMode === 'broadcast' || configuredMode === 'websocket') {
return configuredMode;
}
return isProduction ? 'websocket' : 'broadcast';
}
export function buildRealtimeWebSocketUrl(
configuredUrl?: string,
origin?: string,
): string {
const baseOrigin = origin ?? window.location.origin;
const url = new URL(configuredUrl || '/ws', baseOrigin);
if (url.protocol === 'https:') {
url.protocol = 'wss:';
}
if (url.protocol === 'http:') {
url.protocol = 'ws:';
}
return url.href;
}
+55
View File
@@ -0,0 +1,55 @@
import { isFocusEvent } from './focusEventGuard';
import type { FocusEvent, RealtimeChannel, RealtimeListener } from '../types/channel';
interface MinimalWebSocket {
send: (data: string) => void;
close: (code?: number, reason?: string) => void;
onmessage: ((event: MessageEvent) => void) | null;
}
type WebSocketConstructor = new (url: string) => MinimalWebSocket;
export function createWebSocketRealtimeChannel(
url: string,
WebSocketConstructor: WebSocketConstructor = WebSocket,
): RealtimeChannel {
const socket = new WebSocketConstructor(url);
let closed = false;
return {
publish(message: FocusEvent) {
if (closed) {
return;
}
socket.send(JSON.stringify(message));
},
subscribe(listener: RealtimeListener) {
socket.onmessage = (event) => {
let data: unknown;
try {
data = JSON.parse(String(event.data));
} catch {
return;
}
if (isFocusEvent(data)) {
listener(data);
}
};
return () => {
socket.onmessage = null;
};
},
close() {
if (closed) {
return;
}
closed = true;
socket.close();
},
};
}
+112
View File
@@ -0,0 +1,112 @@
:root {
--background: #ffffff;
--surface: #f6f6f3;
--surface-warm: #e5e5e0;
--text-primary: #211922;
--text-strong: #000000;
--text-secondary: #62625b;
--text-meta: #91918c;
--border: #c8c8c1;
--border-soft: #e0e0d9;
--accent: #e60023;
--success: #103c25;
--focus-ring: 0 0 0 3px rgba(67, 94, 229, 0.35);
--elev-raised: 0 4px 16px rgba(33, 25, 34, 0.06);
--font-body: "Pin Sans", -apple-system, system-ui, "Segoe UI", Roboto, Arial, sans-serif;
--font-art: "Pin Sans", -apple-system, "PingFang SC", "Hiragino Sans GB", "Noto Sans SC", "Microsoft YaHei", system-ui, sans-serif;
--text-xs: 12px;
--text-sm: 14px;
--text-base: 16px;
--text-lg: 18px;
--text-xl: 22px;
--text-2xl: 28px;
--leading-body: 1.4;
--tracking-display: -0.02em;
--space-1: 8px;
--space-2: 12px;
--space-3: 16px;
--space-4: 24px;
--space-5: 32px;
--space-6: 48px;
--radius-sm: 12px;
--radius-md: 16px;
--radius-pill: 9999px;
--motion-fast: 180ms;
--motion-normal: 280ms;
--motion-focus: 720ms;
--motion-hold: 5500ms;
--motion-return: 680ms;
--ease-camera: cubic-bezier(0.32, 0.72, 0.34, 1);
--cloud-scale: 1;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
min-height: 100%;
}
body {
background: var(--background);
color: var(--text-primary);
font-family: var(--font-body);
font-size: var(--text-base);
line-height: var(--leading-body);
-webkit-font-smoothing: antialiased;
}
#root {
min-height: 100dvh;
}
button,
input {
font: inherit;
}
button {
border: 0;
cursor: pointer;
}
button:focus-visible,
input:focus-visible,
[tabindex]:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
+12
View File
@@ -0,0 +1,12 @@
import '@testing-library/jest-dom/vitest';
window.matchMedia = (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => undefined,
removeListener: () => undefined,
addEventListener: () => () => undefined,
removeEventListener: () => undefined,
dispatchEvent: () => false,
});
+18
View File
@@ -0,0 +1,18 @@
export interface FocusEvent {
type: 'FOCUS_NAME';
cloudId: string;
name: string;
targetIndex?: number;
messageId: string;
sentAt: number;
}
export type RealtimeMessage = FocusEvent;
export type RealtimeListener = (message: RealtimeMessage) => void;
export interface RealtimeChannel {
publish: (message: RealtimeMessage) => void;
subscribe: (listener: RealtimeListener) => () => void;
close: () => void;
}
+30
View File
@@ -0,0 +1,30 @@
export type WordTier = 1 | 2 | 3 | 4;
export interface WordCloudWord {
id: string;
name: string;
x: number;
y: number;
fontSize: number;
sourceBox?: { width: number; height: number };
rotation?: number;
tier: WordTier;
}
export interface WordCloudTheme {
accent: string;
foreground: string;
background: string;
}
export interface WordCloud {
id: string;
title: string;
subtitle: string;
eyebrow: string;
caption?: string;
words: WordCloudWord[];
theme: WordCloudTheme;
sourceCanvas?: { width: number; height: number };
sourceSvg?: string;
}
+16
View File
@@ -0,0 +1,16 @@
/// <reference types="vite/client" />
declare module '*.module.css' {
const classes: { readonly [key: string]: string };
export default classes;
}
interface ImportMetaEnv {
readonly VITE_REALTIME_MODE?: 'broadcast' | 'websocket';
readonly VITE_REALTIME_WS_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2020"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"types": ["vitest/globals", "@testing-library/jest-dom"]
},
"include": ["src", "vite.config.ts", "eslint.config.js"]
}
+26
View File
@@ -0,0 +1,26 @@
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/ws': {
target: 'ws://127.0.0.1:8787',
ws: true,
},
'/wordcloud-api': {
target: process.env.WORDCLOUD_API_UPSTREAM || 'http://192.168.31.213:8000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/wordcloud-api/, ''),
},
},
},
test: {
include: ['src/**/*.{test,spec}.{ts,tsx}'],
environment: 'jsdom',
globals: true,
setupFiles: './src/test/setup.ts',
css: true,
},
});