このレシピは @event/spatial(実装済み・検証済み)をイベント app に配線する完全手順。 タイムテーブルは 4S の公開セッションから直接組む(listSessionsFromEvent)。会場マップは サンプル config の最小デモ。PNG 書き出しは @event/asset-kit/capture の exportPng に 委譲する(アプリ側に canvas コードは書かない)。対象 app を apps/<app> とする (例では apps/starter)。
/timetable は公開ページ(認証不要)。gate は無い。会場図の SvgHotspotLayer は "use client" プリミティブなので、必ず client 境界(VenueMap.tsx / ScheduleMapView.tsx)を 通してから使う。
1. 依存を追加
apps/<app>/package.json の dependencies に追加:
"@event/spatial": "*",
"@event/asset-kit": "*"
apps/<app>/next.config.ts の transpilePackages 配列に "@event/spatial" と "@event/asset-kit" を足す。(starter には既に全部入っている。)
2. 機能を有効化 + descriptor
apps/<app>/src/event.config.ts の features で:
spatial: true,
FEATURES 配列に spatial descriptor が無ければ追加する(starter は下記を持つ):
{
key: "spatial",
label: "マップ & タイムテーブル",
description: "会場 SVG ホットスポット + 4S セッションのレーン割当タイムテーブル(@event/spatial)",
nav: [
// /timetable は公開ページ。ここは admin シェルからのプレビュー導線として登録する。
{ id: "spatial-timetable", group: "空間", label: "タイムテーブル(プレビュー)", href: "/timetable", code: "TT", order: 1 },
],
env: [
{ name: "FOURS_EVENT_SLUG", required: true, description: "4S イベント slug(公開セッション取得)" },
{ name: "FOURS_PUBLIC_READ", required: true, description: "4S 公開読み出しを有効化(=1)" },
],
manualChapters: [35],
// requires なし: /timetable は listSessionsFromEvent で 4S を直接読む(sessionCard overlay 非依存)。
// 会場マップはサンプル config だけで自己完結する。
},
3. spatial の配線ファイル
apps/<app>/src/lib/spatial.ts — 4S セッション → timetable model 写像 + room ラベル + 会場サンプル config:
import {
adaptEventToVenues,
createFoursConfig,
fetchEvent,
listSessionsFromEvent,
} from "@event/fours-sdk";
import type { Session } from "@event/schema";
import {
packTimeslots,
dayKey,
type LanedTimeslot,
type SpatialConfig,
type Hotspot,
} from "@event/spatial";
/** 1 日分のタイムテーブル(JST day-key ごとにレーン割当済み)。 */
export interface TimetableDay {
day: string; // 'YYYY-MM-DD'(JST)
slots: LanedTimeslot[]; // 重なるセッションは横並びレーンに詰め済み
startMin: number; // その日の最小開始分 / 最大終了分(JST 壁時計分)
endMin: number;
}
/** 公開セッションを JST 日付でグルーピングし各日を lanePack する純関数。 */
export function buildTimetableDays(sessions: Session[]): TimetableDay[] {
const byDay = new Map<string, Session[]>();
for (const s of sessions) {
const key = dayKey(s.startsAt);
const arr = byDay.get(key);
if (arr) arr.push(s);
else byDay.set(key, [s]);
}
const days: TimetableDay[] = [];
for (const [day, list] of [...byDay.entries()].sort(([a], [b]) => a.localeCompare(b))) {
const slots = packTimeslots(list); // assignLanes で lane / lanes(同時並行数)を付与
if (slots.length === 0) continue;
days.push({
day,
slots,
startMin: Math.min(...slots.map((s) => s.startMin)),
endMin: Math.max(...slots.map((s) => s.endMin)),
});
}
return days;
}
/** room フィルタの選択肢 1 件(client へ直列化可能な最小形)。 */
export interface RoomOption {
id: string;
label: string;
}
/** ScheduleMapView(/timetable フルページ)へ渡すデータ一式。 */
export interface ScheduleMapData {
days: TimetableDay[];
rooms: RoomOption[];
}
/**
* ScheduleMapView 用のデータ整形。タイムテーブル(日別レーン割当済み)に加え、
* room フィルタ用のラベル(4S stages → Room。adaptEventToVenues 経由)を返す。
* セッションに実際に登場する roomId だけに絞る。4S が stub / 空でも落ちない
* (days=[] / rooms=[])。
*/
export async function loadScheduleMapData(): Promise<ScheduleMapData> {
const config = createFoursConfig();
const sessions = (await listSessionsFromEvent(config)) ?? [];
const publicSessions = sessions.filter((s) => s.visibility === "public");
const days = buildTimetableDays(publicSessions);
const usedRoomIds = new Set(publicSessions.map((s) => s.roomId));
const event = await fetchEvent(config); // fetchEvent は SDK 内 TTL キャッシュ済み(追加コストほぼ無し)
const rooms: RoomOption[] = event
? adaptEventToVenues(event)
.flatMap((v) => v.rooms)
.filter((r) => usedRoomIds.has(r.id))
.map((r) => ({ id: r.id, label: r.nameJa }))
: [];
return { days, rooms };
}
/** ホットスポットの data ペイロード型(onSelect で room 名を出すため)。 */
export interface VenueBooth {
code: string;
name: string;
}
const VENUE_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300">
<rect x="8" y="8" width="384" height="284" rx="10" fill="#f4f4f5" stroke="#d4d4d8" stroke-width="1.5"/>
<text x="200" y="30" text-anchor="middle" font-size="12" fill="#71717a">Sample Venue (demo)</text>
<rect x="40" y="60" width="120" height="80" rx="6" fill="#e0e7ff" stroke="#a5b4fc" stroke-width="1.5"/>
<text x="100" y="105" text-anchor="middle" font-size="13" fill="#3730a3">Room A</text>
<rect x="240" y="60" width="120" height="80" rx="6" fill="#dcfce7" stroke="#86efac" stroke-width="1.5"/>
<text x="300" y="105" text-anchor="middle" font-size="13" fill="#166534">Room B</text>
<rect x="140" y="180" width="120" height="80" rx="6" fill="#fef9c3" stroke="#fde047" stroke-width="1.5"/>
<text x="200" y="225" text-anchor="middle" font-size="13" fill="#854d0e">Room C</text>
</svg>`;
/** overlay 矩形は上の SVG の各 Room 図形に一致させる。 */
const VENUE_HOTSPOTS: Hotspot[] = [
{ id: "room-a", label: "Room A — メインステージ", shape: { kind: "rect", x: 40, y: 60, w: 120, h: 80, rx: 6 }, data: { code: "A", name: "メインステージ" } satisfies VenueBooth },
{ id: "room-b", label: "Room B — スタジオ", shape: { kind: "rect", x: 240, y: 60, w: 120, h: 80, rx: 6 }, data: { code: "B", name: "スタジオ" } satisfies VenueBooth },
{ id: "room-c", label: "Room C — 展示ブース", shape: { kind: "rect", x: 140, y: 180, w: 120, h: 80, rx: 6 }, data: { code: "C", name: "展示ブース" } satisfies VenueBooth },
];
/** 会場 1 つ分の空間 config。実イベントでは overview.svgUrl + 実座標に置き換える。 */
export const venueSpatial: SpatialConfig = {
overview: {
id: "venue-overview",
label: "会場全体図(サンプル)",
svg: VENUE_SVG,
viewBox: { w: 400, h: 300 },
hotspots: VENUE_HOTSPOTS,
},
};
4. 会場マップ client コンポーネント
apps/<app>/src/app/timetable/VenueMap.tsx("use client"):
"use client";
import { useState } from "react";
import { SvgHotspotLayer } from "@event/spatial";
import { venueSpatial, type VenueBooth } from "@/lib/spatial";
export function VenueMap() {
const [selectedId, setSelectedId] = useState<string | null>(null);
const source = venueSpatial.overview;
if (!source) return null;
const hotspots = source.hotspots ?? [];
const selected = hotspots.find((h) => h.id === selectedId);
const booth = selected?.data as VenueBooth | undefined;
return (
<div style={{ maxWidth: 480 }}>
<SvgHotspotLayer
svg={source.svg}
viewBox={source.viewBox}
hotspots={hotspots}
selectedId={selectedId}
onSelect={(id) => setSelectedId(id)}
ariaLabel="会場マップ(サンプル)"
style={{ border: "1px solid #e4e4e7", borderRadius: 12, background: "#fff" }}
/>
<p style={{ marginTop: 12, fontSize: 14, minHeight: 20 }}>
{booth ? (
<>
<b>{booth.code}</b>
<span style={{ marginLeft: 8 }}>{booth.name}</span>
</>
) : (
<span style={{ opacity: 0.55 }}>ブースを選択すると room 名が出ます</span>
)}
</p>
</div>
);
}
5. 公開タイムテーブルページ(server = データ整形のみ)
apps/<app>/src/app/timetable/page.tsx(server component・認証不要)。描画・タブ・ フィルタ・PNG 書き出しは全て次ステップの ScheduleMapView(client)に委譲する:
import { loadScheduleMapData } from "@/lib/spatial";
import { ScheduleMapView } from "./ScheduleMapView";
export const dynamic = "force-dynamic";
export default async function TimetablePage() {
const { days, rooms } = await loadScheduleMapData();
return (
<main style={{ maxWidth: 860, margin: "0 auto", padding: "48px 24px" }}>
<h1 style={{ fontSize: 26, margin: "0 0 4px" }}>タイムテーブル & 会場マップ</h1>
<p style={{ fontSize: 13, opacity: 0.6, margin: "0 0 24px" }}>
4S 公開セッションを時刻順に配置。重なるセッションは自動でレーン分割されます(全時刻 JST)。
表示中のタイムテーブルは PNG に書き出せます。
</p>
<ScheduleMapView days={days} rooms={rooms} />
</main>
);
}
6. ScheduleMap ページ(タブ + フィルタ + PNG 書き出し)
apps/<app>/src/app/timetable/ScheduleMapView.tsx("use client")。 @event/spatial が意図的に deferred としていた「ScheduleMap ページ全体」+「PNG エクスポート」の アプリ層実装。ポイント:
- タブ: 「タイムテーブル」(day 切替 + フィルタ + 詳細パネル)/「会場マップ」(VenueMap)。
- フィルタ後は
packTimeslotsでレーンを詰め直す(歯抜けレーンを防ぐ。純関数なので client でも呼べる)。 - PNG 書き出しは表示中グリッドと同一の React ツリーを
exportPng(@event/asset-kit/capture)へ
渡す。実寸(getBoundingClientRect)を測って { w, h, exportScale: 2 } で 2x で焼く。 自前 canvas は書かない。
"use client";
import { useMemo, useRef, useState } from "react";
import type { CSSProperties } from "react";
import { fmtDateLong, fmtDayLabel, fmtTimeRange, packTimeslots } from "@event/spatial";
import { exportPng } from "@event/asset-kit/capture";
import type { Session } from "@event/schema";
import type { RoomOption, TimetableDay } from "@/lib/spatial";
import { VenueMap } from "./VenueMap";
const PX_PER_MIN = 1.4;
const GUTTER_W = 56;
const pad2 = (n: number) => String(n).padStart(2, "0");
/** JST 壁時計分(0-1439)→ "HH:MM"。軸ラベル用(分値なので TZ 非依存)。 */
const hhmm = (min: number) => `${pad2(Math.floor(min / 60))}:${pad2(min % 60)}`;
const selectStyle: CSSProperties = {
padding: "6px 10px",
fontSize: 13,
color: "var(--ink)",
background: "var(--surface)",
border: "1px solid var(--line)",
borderRadius: 6,
fontFamily: "inherit",
};
const chipStyle: CSSProperties = {
fontSize: 12,
padding: "2px 8px",
border: "1px solid var(--line)",
borderRadius: 999,
background: "var(--bg)",
};
/* TimetableSheet — 画面表示と PNG 書き出しで共用するグリッド 1 日分(状態を持たない純描画) */
function TimetableSheet({
day,
selectedId,
onSelectSession,
}: {
day: TimetableDay;
selectedId?: string | null;
onSelectSession?: (session: Session) => void;
}) {
// 軸は時(60分)境界に丸める。
const axisStart = Math.floor(day.startMin / 60) * 60;
const axisEnd = Math.ceil(day.endMin / 60) * 60;
const height = Math.max(48, (axisEnd - axisStart) * PX_PER_MIN);
const hours: number[] = [];
for (let m = axisStart; m <= axisEnd; m += 60) hours.push(m);
const label = fmtDayLabel(day.day);
return (
<div
style={{
background: "var(--surface)",
border: "1px solid var(--line)",
borderRadius: 8,
padding: "16px 20px",
}}
>
<h2 style={{ fontSize: 18, margin: "0 0 12px" }}>
{label.date}
<span style={{ fontSize: 12, opacity: 0.5, marginLeft: 8 }}>{label.weekday}</span>
<span style={{ fontSize: 11, opacity: 0.4, marginLeft: 8, fontFamily: "var(--mono)" }}>
JST
</span>
</h2>
<div style={{ display: "flex", alignItems: "stretch" }}>
{/* 時刻ガター */}
<div style={{ width: GUTTER_W, position: "relative", height, flex: "0 0 auto" }}>
{hours.map((m) => (
<div
key={m}
style={{
position: "absolute",
top: (m - axisStart) * PX_PER_MIN - 7,
right: 8,
fontSize: 11,
opacity: 0.55,
fontFamily: "var(--mono)",
}}
>
{hhmm(m)}
</div>
))}
</div>
{/* スロット領域 */}
<div
style={{
position: "relative",
flex: 1,
height,
borderLeft: "1px solid var(--line)",
}}
>
{/* 時グリッド線 */}
{hours.map((m) => (
<div
key={m}
style={{
position: "absolute",
left: 0,
right: 0,
top: (m - axisStart) * PX_PER_MIN,
borderTop: "1px solid var(--line)",
opacity: 0.45,
}}
/>
))}
{/* 各セッション(lane / lanes で横位置と幅を決める) */}
{day.slots.map((slot) => {
const top = (slot.startMin - axisStart) * PX_PER_MIN;
const h = Math.max(24, (slot.endMin - slot.startMin) * PX_PER_MIN - 2);
const leftPct = (slot.lane / slot.lanes) * 100;
const widthPct = (1 / slot.lanes) * 100;
const selected = selectedId === slot.session.id;
return (
<button
key={slot.session.id}
type="button"
title={slot.session.title}
onClick={onSelectSession ? () => onSelectSession(slot.session) : undefined}
style={{
position: "absolute",
top,
height: h,
left: `${leftPct}%`,
width: `calc(${widthPct}% - 6px)`,
marginLeft: 4,
overflow: "hidden",
boxSizing: "border-box",
padding: "5px 7px",
borderRadius: 6,
background: "var(--accent-soft)",
border: selected ? "1px solid var(--accent)" : "1px solid var(--line)",
boxShadow: selected ? "0 0 0 1px var(--accent) inset" : "none",
fontSize: 12,
lineHeight: 1.3,
textAlign: "left",
display: "block",
color: "inherit",
fontFamily: "inherit",
cursor: onSelectSession ? "pointer" : "default",
}}
>
<span
style={{ display: "block", opacity: 0.6, fontSize: 10, fontFamily: "var(--mono)" }}
>
{fmtTimeRange(slot.session)}
</span>
<span style={{ display: "block", fontWeight: 600 }}>{slot.session.title}</span>
</button>
);
})}
</div>
</div>
</div>
);
}
export interface ScheduleMapViewProps {
days: TimetableDay[];
rooms: RoomOption[];
}
export function ScheduleMapView({ days, rooms }: ScheduleMapViewProps) {
const [tab, setTab] = useState<"timetable" | "map">("timetable");
const [dayIdx, setDayIdx] = useState(0);
const [trackFilter, setTrackFilter] = useState("all");
const [roomFilter, setRoomFilter] = useState("all");
const [selectedId, setSelectedId] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [status, setStatus] = useState<string | null>(null);
const sheetRef = useRef<HTMLDivElement | null>(null);
const activeDay = days.length > 0 ? days[Math.min(dayIdx, days.length - 1)]! : null;
const roomLabelOf = useMemo(() => new Map(rooms.map((r) => [r.id, r.label])), [rooms]);
// フィルタ選択肢は全日から集める(day を跨いでも選択が消えない)。
const trackOptions = useMemo(() => {
const set = new Set<string>();
for (const d of days) for (const sl of d.slots) if (sl.session.track) set.add(sl.session.track);
return [...set].sort();
}, [days]);
const roomOptions = useMemo(() => {
const set = new Set<string>();
for (const d of days) for (const sl of d.slots) set.add(sl.session.roomId);
return [...set].sort().map((id) => ({ id, label: roomLabelOf.get(id) ?? id }));
}, [days, roomLabelOf]);
// フィルタ適用 → packTimeslots でレーンを詰め直した表示用 1 日分。
const viewDay: TimetableDay | null = useMemo(() => {
if (!activeDay) return null;
const sessions = activeDay.slots
.map((sl) => sl.session)
.filter(
(s) =>
(trackFilter === "all" || s.track === trackFilter) &&
(roomFilter === "all" || s.roomId === roomFilter),
);
if (sessions.length === 0) return { day: activeDay.day, slots: [], startMin: 0, endMin: 0 };
const slots = packTimeslots(sessions);
return {
day: activeDay.day,
slots,
startMin: Math.min(...slots.map((s) => s.startMin)),
endMin: Math.max(...slots.map((s) => s.endMin)),
};
}, [activeDay, trackFilter, roomFilter]);
const selectedSession =
viewDay?.slots.find((sl) => sl.session.id === selectedId)?.session ?? null;
async function onExportPng() {
const el = sheetRef.current;
if (!el || !viewDay || viewDay.slots.length === 0) return;
setBusy(true);
setStatus(null);
try {
// 表示中グリッドの実寸を測り、同一ツリーを同幅で exportPng に渡す(2x)。
const w = Math.round(el.getBoundingClientRect().width);
const h = Math.ceil(el.getBoundingClientRect().height);
const bg =
getComputedStyle(document.documentElement).getPropertyValue("--surface").trim() ||
"#ffffff";
await exportPng(
<div style={{ width: w, boxSizing: "border-box" }}>
<TimetableSheet day={viewDay} />
</div>,
{ w, h, exportScale: 2 },
bg,
`timetable-${viewDay.day}`,
);
setStatus("PNG を書き出しました");
} catch (e) {
setStatus(`書き出しに失敗: ${String(e)}`);
} finally {
setBusy(false);
}
}
const tabButton = (id: "timetable" | "map", label: string) => (
<button
type="button"
onClick={() => setTab(id)}
aria-pressed={tab === id}
style={{
padding: "8px 18px",
fontSize: 13,
fontWeight: 600,
color: tab === id ? "var(--accent)" : "var(--ink-2)",
background: tab === id ? "var(--accent-soft)" : "transparent",
border: `1px solid ${tab === id ? "var(--accent)" : "var(--line)"}`,
borderRadius: 6,
cursor: "pointer",
fontFamily: "inherit",
}}
>
{label}
</button>
);
return (
<div>
{/* タブ */}
<div style={{ display: "flex", gap: 8, marginBottom: 20 }}>
{tabButton("timetable", "タイムテーブル")}
{tabButton("map", "会場マップ")}
</div>
{tab === "map" ? (
<section>
<p style={{ fontSize: 13, opacity: 0.6, margin: "0 0 16px" }}>
SvgHotspotLayer のデモ。ブースをクリックすると room 名が出ます。
</p>
<VenueMap />
</section>
) : days.length === 0 ? (
<p style={{ fontSize: 14, opacity: 0.6 }}>
公開セッションがまだありません(4S が stub / 未接続の場合は空表示になります)。
</p>
) : (
<section>
{/* day 切替 */}
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 12 }}>
{days.map((d, i) => {
const label = fmtDayLabel(d.day);
const active = activeDay?.day === d.day;
return (
<button
key={d.day}
type="button"
onClick={() => {
setDayIdx(i);
setSelectedId(null);
}}
aria-pressed={active}
style={{
padding: "5px 12px",
fontSize: 12,
fontFamily: "var(--mono)",
color: active ? "var(--accent)" : "var(--ink-2)",
background: active ? "var(--accent-soft)" : "transparent",
border: `1px solid ${active ? "var(--accent)" : "var(--line)"}`,
borderRadius: 999,
cursor: "pointer",
}}
>
{label.date} {label.weekday}
</button>
);
})}
</div>
{/* フィルタ + PNG 書き出し */}
<div
style={{
display: "flex",
gap: 10,
alignItems: "center",
flexWrap: "wrap",
marginBottom: 16,
}}
>
{trackOptions.length > 0 && (
<select
aria-label="トラックで絞り込む"
value={trackFilter}
onChange={(e) => setTrackFilter(e.target.value)}
style={selectStyle}
>
<option value="all">すべてのトラック</option>
{trackOptions.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
)}
{roomOptions.length > 0 && (
<select
aria-label="会場で絞り込む"
value={roomFilter}
onChange={(e) => setRoomFilter(e.target.value)}
style={selectStyle}
>
<option value="all">すべての会場</option>
{roomOptions.map((r) => (
<option key={r.id} value={r.id}>
{r.label}
</option>
))}
</select>
)}
<div style={{ marginLeft: "auto", display: "flex", gap: 10, alignItems: "center" }}>
{status && (
<span style={{ fontSize: 12, fontFamily: "var(--mono)", opacity: 0.75 }}>
{status}
</span>
)}
<button
type="button"
onClick={onExportPng}
disabled={busy || !viewDay || viewDay.slots.length === 0}
style={{
padding: "8px 16px",
fontSize: 13,
fontWeight: 600,
color: "var(--surface)",
background: "var(--accent)",
border: "none",
borderRadius: 6,
cursor: busy ? "default" : "pointer",
opacity: busy || !viewDay || viewDay.slots.length === 0 ? 0.55 : 1,
fontFamily: "inherit",
}}
>
{busy ? "書き出し中…" : "PNG 書き出し"}
</button>
</div>
</div>
{/* グリッド本体(ref は PNG 書き出しの実寸計測用) */}
{viewDay && viewDay.slots.length > 0 ? (
<div ref={sheetRef}>
<TimetableSheet
day={viewDay}
selectedId={selectedId}
onSelectSession={(s) => setSelectedId(s.id === selectedId ? null : s.id)}
/>
</div>
) : (
<p style={{ fontSize: 14, opacity: 0.6 }}>
フィルタに一致するセッションがありません。
</p>
)}
{/* セッション詳細パネル(下部・モーダルではない) */}
{selectedSession && viewDay && (
<aside
aria-label="セッション詳細"
style={{
marginTop: 16,
padding: "14px 16px",
background: "var(--surface)",
border: "1px solid var(--line)",
borderLeft: "3px solid var(--accent)",
borderRadius: 8,
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
gap: 12,
}}
>
<div>
<div style={{ fontSize: 11, fontFamily: "var(--mono)", opacity: 0.6 }}>
{fmtDateLong(viewDay.day)} {fmtTimeRange(selectedSession)}(
{selectedSession.durationMin}分)
</div>
<h3 style={{ fontSize: 16, margin: "4px 0 0" }}>{selectedSession.title}</h3>
</div>
<button
type="button"
onClick={() => setSelectedId(null)}
style={{
fontSize: 12,
padding: "4px 10px",
color: "var(--ink-2)",
background: "transparent",
border: "1px solid var(--line)",
borderRadius: 6,
cursor: "pointer",
fontFamily: "inherit",
flex: "0 0 auto",
}}
>
閉じる
</button>
</div>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginTop: 10 }}>
<span style={chipStyle}>
会場: {roomLabelOf.get(selectedSession.roomId) ?? selectedSession.roomId}
</span>
{selectedSession.track && <span style={chipStyle}>トラック: {selectedSession.track}</span>}
{selectedSession.speakerIds.length > 0 && (
<span style={chipStyle}>登壇者 {selectedSession.speakerIds.length} 名</span>
)}
</div>
{selectedSession.description && (
<p style={{ fontSize: 13, margin: "10px 0 0", opacity: 0.8 }}>
{selectedSession.description}
</p>
)}
</aside>
)}
</section>
)}
</div>
);
}
注: CSS 変数(--ink / --line / --surface / --accent / --accent-soft / --mono 等)は app の globals.css が定義するものを使う。app 側の変数名が違う場合は読み替える。
7. インストールと型チェック
monorepo root で:
yarn install
yarn workspace <app> typecheck
検証
yarn workspace <app> dev # http://localhost:3401
/timetableを開く → 「タイムテーブル」タブがアクティブ。4S が接続済み
(FOURS_EVENT_SLUG + FOURS_PUBLIC_READ=1)なら day チップが並び、選択日の グリッドに公開セッションが時刻順で描画され、同時刻のセッションはレーン分割される。 stub(未接続)なら「公開セッションがまだありません」の空表示(落ちない)。
- day チップで日を切り替え → グリッドが差し替わる。track / room の select で絞り込むと
レーンが詰め直される(歯抜けにならない)。
- セッションをクリック → 下部に詳細パネル(日時・会場・トラック・概要)。もう一度クリック
または「閉じる」で閉じる。
- 「PNG 書き出し」→
timetable-YYYY-MM-DD.pngがダウンロードされる(表示中のフィルタ
結果がそのまま 2x で焼かれる)。
- 「会場マップ」タブ → Room A/B/C をクリックすると下に room 名(例「A メインステージ」)が出る。
完了条件: typecheck が通り、/timetable が公開で開け、タブ・day 切替・フィルタ・詳細パネルが 機能し、PNG 書き出しでグリッドが画面と同一の見た目でダウンロードされる。