レシピ · 7手 · 1プロンプト実装

マップ & タイムテーブルを追加する

4S 公開セッションから、タブ(タイムテーブル / 会場マップ)+ day 切替 + track/room フィルタ + セッション詳細パネル + PNG 書き出し付きの ScheduleMap フルページを公開ページ /timetable に配線する

前提: new-event前提: add-session-cards解説 ch.35module: マップ & タイムテーブル

このレシピは @event/spatial(実装済み・検証済み)をイベント app に配線する完全手順。 タイムテーブルは 4S の公開セッションから直接組む(listSessionsFromEvent)。会場マップは サンプル config の最小デモ。PNG 書き出しは @event/asset-kit/captureexportPng に 委譲する(アプリ側に 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.tstranspilePackages 配列に "@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 エクスポート」の アプリ層実装。ポイント:

渡す。実寸(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
  1. /timetable を開く → 「タイムテーブル」タブがアクティブ。4S が接続済み

FOURS_EVENT_SLUG + FOURS_PUBLIC_READ=1)なら day チップが並び、選択日の グリッドに公開セッションが時刻順で描画され、同時刻のセッションはレーン分割される。 stub(未接続)なら「公開セッションがまだありません」の空表示(落ちない)。

  1. day チップで日を切り替え → グリッドが差し替わる。track / room の select で絞り込むと

レーンが詰め直される(歯抜けにならない)。

  1. セッションをクリック → 下部に詳細パネル(日時・会場・トラック・概要)。もう一度クリック

または「閉じる」で閉じる。

  1. 「PNG 書き出し」→ timetable-YYYY-MM-DD.png がダウンロードされる(表示中のフィルタ

結果がそのまま 2x で焼かれる)。

  1. 「会場マップ」タブ → Room A/B/C をクリックすると下に room 名(例「A メインステージ」)が出る。

完了条件: typecheck が通り、/timetable が公開で開け、タブ・day 切替・フィルタ・詳細パネルが 機能し、PNG 書き出しでグリッドが画面と同一の見た目でダウンロードされる。