---
nav:
  title: 组件
  order: 1
group:
  title: Data Display
  order: 3
title: PisellFloorMapLayout
order: 10
category: pro
---

# PisellFloorMapLayout - 空间布局视图

## 简介

PisellFloorMapLayout 是**空间布局视图**组件，在二维平面内按坐标渲染资源（桌台、房间、工位、设备等），并将资源状态通过
`renderItem` 映射为颜色、边框、标签等视觉表现，同时输出点击、悬停、缩放、平移等交互事件。

**定位**：该组件仅负责**展示与交互**，不负责数据获取与业务规则；接收资源配置与状态数据，通过 `items` 与 `renderItem` 渲染，通过
`onItemClick`、`onItemHover`、`onItemLeave` 等向外抛出事件。适用于餐厅桌台图、会议室/工位图、场地平面图等场景。

**设计资源**：Figma Pisell 2.0 Component Library 中对应节点为 **14004:1168**（本期默认矩形、规则排列）与 **13998:648**
（无规则、多尺寸像素布局）；视图控制位置见 **14131:6265**，顶部 Tab 与图例见 **14131:6266**。

## 特性

- 📐 **双单位**：支持 **cell**（格子数）与 **pixel**（像素）两种坐标与尺寸单位；cell 模式下由 `cellSize` 与 `mapLayer.gap`
  控制像素与间距。
- 🔍 **缩放平移**：基于 `react-zoom-pan-pinch`，支持滚轮/捏合缩放、拖拽/手势平移；可配置默认缩放、最小/最大缩放、步进与平移边界策略。
- 🎛 **视图控制**：左下角内置控制条，可配置显示**放大/缩小**、**复位**、**区域定位**（fitBounds）、**全屏**；默认仅显示放大/缩小。
- 🗺 **底图层**：支持无背景 / 纯色 / 图片 / SVG 背景、可选网格线、底层 padding 与 gap。
- 🎯 **Item 交互**：支持 hover 高亮与开关（`enableHover`），以及 `onItemClick`、`onItemHover`、`onItemLeave` 事件。
- 🎭 **TypeScript**：完整类型导出（Props、Ref、FloorMapItemBase、各类 Config、Shape 等）。
- 📦 **可选 Context**：**`FloorMapLayoutProvider`** 注入 **`FloorMapLayoutContextValue`**（配置/保存/数据源/侧栏表单等）；*
  *`PisellRecordBoard`** 根级 **`floorMapLayoutContext`** 由 Shell **内置 Provider**，无需再包一层。

## 运行模式与代码结构

### 两种用法

| 模式       | 条件                      | 渲染数据来源                                                                                                                                            | 说明                                                                                                        |
|----------|-------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------|
| **基础布局** | 不传 `floorMapConfig`     | 外部传入的 **`items`** + **`renderItem`**                                                                                                              | 与早期版本一致；`itemUnit` 可为 `cell` 或 `pixel`。                                                                   |
| **平面图**  | 传入 **`floorMapConfig`** | 内部用 **`mergeCanvasItems`** 合并当前画布的 **`sceneElements`** 得到 **`FloorMapMergedItem[]`**（`id` = `instanceId`），作为内部 `items`；**`itemUnit` 强制为 `pixel`** | 需同时提供 **`dataSources`**；行级绑定见各图元的 **`dataBinding`**；可选 **`renderItemByKind`**、**`onElementRenderError`**。 |

逻辑封装在 **`useFloorMapEditState`**（草稿/画布/选中/落位/侧栏参数）与 **`useFloorMapTransformMetrics`**（内容区尺寸、缩放
fitBounds、全屏等）；纯计算在 **`utils/layoutMeasurements.ts`**、**`utils/floorMapConfigMerge.ts`**，占位渲染在 *
*`utils/floorMapFallbackRender.tsx`**。目录与职责详见 [开发文档](./pisellFloorMapLayout.$tab-dev.md)。

### 阅读态下的组件树（未传 floorMapConfig 或 mode='read'）

根容器内由 **react-zoom-pan-pinch** 的 `TransformWrapper` 提供缩放/平移；**`ViewControlsWithZoom`** 在 Wrapper 子树内使用
`useControls` 桥接 **`ViewControls`**。

```
PisellFloorMapLayout 根容器
└── TransformWrapper
    ├── TransformComponent
    │   ├── MapLayer（背景、网格、padding、gap）
    │   └── ItemLayer（items + renderItem，可选 hover/点击）
    └── ViewControlsWithZoom → ViewControls（左下角：缩放 / 复位 / fitBounds / 全屏）
```

### 编辑态下的组件树（floorMapConfig + mode='edit'）

在「阅读态」树之外，增加：**顶部 `FloorMapEditTopBar`（内含 `CanvasTabs` + 保存/全屏/区域定位等）**、**左侧 `FloorMapToolbar`
**、**Transform 内 `EditableItemLayer` 替代 `ItemLayer`**、可选 **落点遮罩**、**拖入图源层**、**右侧 `FloorMapEditPanel`**。

```
PisellFloorMapLayout 根容器
├── FloorMapEditTopBar（toggle：紧凑顶栏含进入全屏 / 全屏后含保存与退出；locked：始终全屏顶栏且无退出；disabled：仅 Tab）
└── 双栏 flex
    ├── FloorMapToolbar（图元调色板可拖至画布、绑定数据；保存见 toolbarShowSave）
    └── 地图区
        └── TransformWrapper
            ├── TransformComponent
            │   ├── MapLayer（编辑态底层通常 pointer-events: none）
            │   ├── EditableItemLayer（选中、拖拽、缩放）或 ItemLayer（只读）
            │   ├── [可选] 落点遮罩 .pisell-floor-map-layout-place-overlay
            │   └── 拖入图源层 .pisell-floor-map-layout-map-drop-layer（激活时接收 drop）
            ├── ViewControlsWithZoom（编辑态下全屏/fitBounds 已迁至顶栏时，此处仅缩放等）
            └── FloorMapEditPanel（图元属性 + 数据源记录）
```

| 节点                                | 职责                                                                        | 主要输入                                              |
|-----------------------------------|---------------------------------------------------------------------------|---------------------------------------------------|
| **PisellFloorMapLayout**          | 编排子组件；`forwardRef` 暴露缩放/fitBounds；注入 `contentOffsetRef` 供落点换算             | 见 **API** 与 `types.ts`                            |
| **useFloorMapEditState**          | `draftConfig`、`mergedItems`、`placeMode`、`editPanelParams`、落点/侧栏回调         | `floorMapConfig`、`dataSources`、`onSave` 等         |
| **useFloorMapTransformMetrics**   | `contentWidth/Height`、`contentOffsetX/Y`、`fitBounds`、全屏、拖拽图元后恢复 transform | `items`、`zoom`、`mode`、refs                        |
| **MapLayer**                      | 底层背景/网格/padding                                                           | `mapLayer`、content 尺寸                             |
| **ItemLayer / EditableItemLayer** | 按坐标渲染图元；编辑态可拖拽缩放                                                          | `items`、`renderItem` 或 `effectiveRenderItem`      |
| **FloorMapEditTopBar**            | 画布 Tab、布局保存、全屏、可选区域定位                                                     | `config`、`onSave`、`controls`、`toggleFullscreen` 等 |
| **FloorMapToolbar**               | 放置模式、数据源预览；保存可选（`toolbarShowSave`）                                        | `config`、`dataSources`、`placeMode`                |
| **FloorMapEditPanel**             | 图元表单；**数据源记录**（绑定行 + `FloorMapDataForm` **或** `dataSourceRecordForm`）     | `editPanelParams`                                 |
| **ViewControlsWithZoom**          | 在 Transform 上下文内连接 `ViewControls`                                         | `fitBounds`、`toggleFullscreen`、`scale`            |

**数据流简述（基础模式）**：外部 `items` + `mapLayer` + `cellSize` + `itemUnit` → `useFloorMapTransformMetrics` 中
`getContentSize` / `getPixelBoundingBox` 得到 content 尺寸与偏移 → MapLayer、ItemLayer；缩放由 `onTransformed` 同步。

**数据流简述（平面图编辑）**：外部 `floorMapConfig` 写入 **`draftConfig`**（内存草稿）→ **`onSave(draftConfig)`**
持久化布局；业务行数据通过 **`onDataSourceRecordSave`** 写回各数据源，**与布局配置分离**。

**使用技术**：

| 技术                       | 用途                                                                                               |
|--------------------------|--------------------------------------------------------------------------------------------------|
| **React**                | 组件与 Hooks；主文件组合双 Hook。                                                                           |
| **TypeScript**           | `types.ts`：`PisellFloorMapLayoutProps`、`FloorMapViewConfig`、`FloorMapRecordFormRenderContext` 等。 |
| **react-zoom-pan-pinch** | `TransformWrapper` / `TransformComponent`、`useControls`。                                         |
| **Less**                 | 类名前缀 `pisell-floor-map-layout`。                                                                  |
| **classNames**           | 根样式变体（floor-map-view、mode-edit）。                                                                 |
| **@ant-design/icons**    | 视图控制图标；侧栏关闭等。                                                                                    |

## 基础用法

```tsx
import React from 'react';
import {PisellFloorMapLayout} from '@pisell/materials';
import type {FloorMapItemBase} from '@pisell/materials';

const items: FloorMapItemBase[] = [
    {id: 'A', x: 0, y: 0},
    {id: 'B', x: 1, y: 0},
    {id: 'C', x: 0, y: 1},
];

const itemColors: Record<string, { bg: string; border: string }> = {
    A: {bg: '#e6f7ff', border: '#1890ff'},
    B: {bg: '#f6ffed', border: '#52c41a'},
    C: {bg: '#fff7e6', border: '#fa8c16'},
};

export default function Demo() {
    return (
        <PisellFloorMapLayout
            items={items}
            renderItem={(item) => {
                const {bg, border} = itemColors[item.id] || {bg: '#f5f5f5', border: '#d9d9d9'};
                return (
                    <div style={{
                        width: '100%',
                        height: '100%',
                        boxSizing: 'border-box',
                        display: 'flex',
                        alignItems: 'center',
                        justifyContent: 'center',
                        background: bg,
                        border: `3px solid ${border}`,
                        borderRadius: 6,
                        fontWeight: 600,
                        color: '#262626'
                    }}>
                        {item.id}
                    </div>
                );
            }}
            mapLayer={{backgroundType: 'color', backgroundColor: '#f0f0f0', padding: 16, gap: 8}}
            zoom={{defaultScale: 1, min: 0.5, max: 2}}
            pan={{enabled: true}}
            style={{height: 400}}
        />
    );
}
```

## 坐标与尺寸单位（itemUnit）

| 单位           | 说明                                                                                         | 适用场景                       |
|--------------|--------------------------------------------------------------------------------------------|----------------------------|
| **cell（默认）** | item 的 `x`、`y`、`width`、`height` 为**格子数**。像素 = 格子数 × `cellSize`，item 间距由 `mapLayer.gap` 控制。 | 规则网格、等格布局（如 5×4 桌台）        |
| **pixel**    | item 的 `x`、`y`、`width`、`height` 为**像素**，自由定位与尺寸。                                           | 不规则排版、多尺寸混合（如 S/M/L/XL 桌型） |

**格子布局示例**（每格 64px，间距 8px）：

```tsx
import React from 'react';
import {PisellFloorMapLayout} from '@pisell/materials';
import type {FloorMapItemBase} from '@pisell/materials';

const items: FloorMapItemBase[] = [
    {id: '1', x: 0, y: 0},
    {id: '2', x: 1, y: 0, width: 2, height: 1},
];

export default function CellDemo() {
    return (
        <PisellFloorMapLayout
            itemUnit="cell"
            cellSize={64}
            mapLayer={{gap: 8, backgroundType: 'color', backgroundColor: '#e8e8e8'}}
            items={items}
            renderItem={(item) => (
                <div
                    style={{
                        width: '100%', height: '100%', boxSizing: 'border-box',
                        display: 'flex', alignItems: 'center', justifyContent: 'center',
                        background: item.id === '1' ? '#d6e4ff' : '#d9f7be',
                        border: `3px solid ${item.id === '1' ? '#597ef7' : '#73d13d'}`,
                        borderRadius: 6, fontWeight: 600, color: '#262626',
                    }}
                >
                    {item.id}
                </div>
            )}
            style={{height: 320}}
        />
    );
}
```

**像素布局示例**（自由位置与尺寸）：

```tsx
import React from 'react';
import {PisellFloorMapLayout} from '@pisell/materials';
import type {FloorMapItemBase} from '@pisell/materials';

const items: FloorMapItemBase[] = [
    {id: '1', x: 20, y: 20, width: 100, height: 80},
    {id: '2', x: 140, y: 20, width: 200, height: 80},
];

export default function PixelDemo() {
    return (
        <PisellFloorMapLayout
            itemUnit="pixel"
            cellSize={64}
            items={items}
            mapLayer={{backgroundType: 'color', backgroundColor: '#f0f0f0', padding: 12}}
            renderItem={(item) => (
                <div
                    style={{
                        width: '100%', height: '100%', boxSizing: 'border-box',
                        display: 'flex', alignItems: 'center', justifyContent: 'center',
                        background: item.id === '1' ? '#ffd6e7' : '#ffe7ba',
                        border: `3px solid ${item.id === '1' ? '#eb2f96' : '#fa8c16'}`,
                        borderRadius: 8, fontWeight: 600, color: '#262626',
                    }}
                >
                    {item.id}
                </div>
            )}
            style={{height: 320}}
        />
    );
}
```

## 视图控制与 Ref

视图控制按钮位于**左下角**（Figma 14131:6265）。默认仅显示**放大（+）**与**缩小（−）**；需在 `controls` 中开启 `showReset`、
`showFitBounds`、`showFullscreen` 才会显示复位、区域定位与全屏按钮。全屏按钮点击后对根容器调用 `requestFullscreen()`，再次点击或按
Esc 退出全屏。

**平面图编辑态**（`floorMapConfig` + `mode='edit'`）：默认 **`floorMapFullscreenMode='toggle'`** 时，**非浏览器全屏**顶栏为*
*画布 Tab + 进入全屏**；**全屏**后顶栏另含保存、`showFitBounds` 区域定位与**退出全屏**。设为 **`locked`** 时始终使用「全屏顶栏」布局且
**不展示退出全屏**（适合外层遮罩铺满）；**`disabled`** 时**无全屏按钮**，保存仍在侧栏（与 `toolbarShowSave` 一致）。左下角控件条保留缩放（及
`showReset` 若开启）。

通过 **ref** 可编程控制视图：

| 方法            | 说明            |
|---------------|---------------|
| `zoomIn()`    | 放大            |
| `zoomOut()`   | 缩小            |
| `reset()`     | 复位到初始缩放与位置    |
| `fitBounds()` | 区域定位，使内容区适应容器 |

```tsx
import React, {useRef} from 'react';
import {PisellFloorMapLayout} from '@pisell/materials';
import type {PisellFloorMapLayoutRef, FloorMapItemBase} from '@pisell/materials';

const items: FloorMapItemBase[] = [{id: '1', x: 0, y: 0}];

export default function Demo() {
    const ref = useRef<PisellFloorMapLayoutRef>(null);
    return (
        <>
            <div style={{marginBottom: 8}}>
                <button type="button" onClick={() => ref.current?.zoomIn()}>放大</button>
                <button type="button" onClick={() => ref.current?.fitBounds()} style={{marginLeft: 8}}>区域定位</button>
            </div>
            <PisellFloorMapLayout
                ref={ref}
                items={items}
                mapLayer={{backgroundType: 'color', backgroundColor: '#e8e8e8', padding: 16}}
                renderItem={(item) => (
                    <div style={{
                        width: '100%',
                        height: '100%',
                        boxSizing: 'border-box',
                        display: 'flex',
                        alignItems: 'center',
                        justifyContent: 'center',
                        background: '#b5f5ec',
                        border: '3px solid #13c2c2',
                        borderRadius: 6,
                        fontWeight: 600,
                        color: '#262626'
                    }}>
                        {item.id}
                    </div>
                )}
                controls={{showReset: true, showFitBounds: true, showFullscreen: true}}
                style={{height: 300}}
            />
        </>
    );
}
```

## 事件

- **onItemClick(item, index, e)**：点击某个 item 时触发。
- **onItemHover(item, index)**：鼠标进入 item 时触发（需 `enableHover: true`，默认开启）。
- **onItemLeave(item, index)**：鼠标离开 item 时触发。

可通过 `enableHover={false}` 关闭 hover 高亮与 hover 事件。

## 平面图与数据源（可选）

当传入 **`floorMapConfig`** 时进入**平面图模式**：

- **布局持久化**：编辑态修改画布列表、落位、装饰等，通过顶栏 **「保存」**（`onSave`）将完整 **`FloorMapViewConfig`**
  （草稿）交给上游保存。若需在左侧工具栏重复保存按钮，可设 **`toolbarShowSave`**。
- **侧栏拖拽落位**：从左侧图元栏**拖拽**图元卡片到画布，在落点生成对应 `sceneElement`
  （规则与先选类型再点击画布落点一致）。不再支持将外部图片文件/链接拖入画布自动转为图源图元。
- **业务记录**：侧栏「数据源记录」通过 **`onDataSourceRecordSave(dataSourceKey, recordId, partialRecord)`** 更新单行；与表格等
  UI 同源时，应在同一 state 上合并，保证列表与画布卡片一致。
- **记录表单**：
  - **`dataSourceFormColumns`**：按数据源 key 配置列，内置 **`FloorMapDataForm`** 自动生成表单项；
  - **`dataSourceRecordForm`**：按 key 传入 **`ReactNode`** 或 **`(ctx: FloorMapRecordFormRenderContext) => ReactNode`**，
    **替换**该 key 下的内置表单，用于复杂校验、分步、联动；`ctx.onSaveRecord` 与内置保存通道一致（需配置
    `onDataSourceRecordSave`）。
- **与 RecordBoard**：`floorMap` 使用本组件 **`Partial<PisellFloorMapLayoutProps>`**；根级还可传 **`floorMapLayoutContext`
  **（与 **`FloorMapLayoutContextValue`** 同形），由 Shell **内置 `FloorMapLayoutProvider`**。完整组合示例见下文「RecordBoard
  根级注入」；Story **PisellRecordBoard → Default** 已拆分 `floorMapLayoutContext` 与精简 `FloorMap`。

### FloorMapLayoutProvider 与数据分流

**合并规则**：`PisellFloorMapLayout` 内部对 Context 承载字段按 **`props ?? context`** 合并，**Layout 上显式传入的 props 优先
**。未使用 Provider 或未传某字段时，行为与原先全部走 props 一致。

**适合放入 Context（`FloorMapLayoutContextValue`）的字段**：`floorMapConfig`、`dataSources`（见下注意）、`onSave`、
`onDataSourceRecordSave`、`dataSourceLabels`、`dataSourceFormColumns`、`dataSourceRecordForm`、`loading`、`saveError`、
`onElementRenderError`、`toolbarShowSave`、`floorMapFullscreenMode`。

**建议仍走 Layout / RecordBoard `floorMap` 的 props**：`renderItem`、`renderItemByKind`、`mapLayer`、`cellSize`、`zoom` /
`pan` / `controls`、`className` / `style`、基础模式 `items`、`enableHover`、`onItemClick` 等展示与交互。

**RecordBoard 与 `dataSources`**：Shell 会将根级 **`dataSources`** 与 **`floorMap.dataSources`** 合并后以 **props** 传入
Layout，**会覆盖** Context 中的 **`dataSources`**。表格与平面图共用多数据源时，请仍使用根级 / `floorMap` 的 **`dataSources`
**，不要仅依赖 **`floorMapLayoutContext.dataSources`**。

**工具栏读取配置**：平面图节点在 **`FloorMapLayoutProvider`** 子树内；**`useRecordBoardContext().floorMapLayoutContext`**
与注入 Provider 的 value **同源**，供 ToolBar 等不在平面图 DOM 子树内的节点读取。

| 数据类型                                 | Context / `floorMapLayoutContext` | 直接 `PisellFloorMapLayout` props | RecordBoard `floorMap` + 根级 `dataSources` |
|--------------------------------------|-----------------------------------|---------------------------------|-------------------------------------------|
| 布局配置、保存回调、加载/错误                      | 推荐                                | 可                               | 可（合并后以 props 进入 Layout）                   |
| 与表格共用的 `dataSources`                 | 易被 RecordBoard 的 props 覆盖         | 可                               | **推荐**                                    |
| `renderItem`、`mapLayer`、`controls` 等 | 不推荐放入 Context                     | 推荐                              | **推荐写在 `floorMap` / `FloorMap`**          |

#### 示例一：单独页面 — `FloorMapLayoutProvider` + Layout（仅展示向在 Layout）

业务在父组件请求/聚合配置与数据源，将 **`value` 用 `useMemo` 稳定化**；子树内任意深度的 **`PisellFloorMapLayout`** 均可合并到
Context。

```tsx
import React, {useCallback, useMemo, useState} from 'react';
import {
    PisellFloorMapLayout,
    FloorMapLayoutProvider,
} from '@pisell/materials';
import type {FloorMapLayoutContextValue, FloorMapViewConfig} from '@pisell/materials';

export default function FloorMapEditorWithProvider() {
    const [config, setConfig] = useState<FloorMapViewConfig | undefined>();
    const [tables, setTables] = useState<Array<{ id: string; name?: string }>>([]);
    const [loading] = useState(false);
    const [saveError, setSaveError] = useState<Error | undefined>();

    const handleSave = useCallback((next: FloorMapViewConfig) => {
        setSaveError(undefined);
        setConfig(next);
        void fetch('/api/floor-map', {
            method: 'PUT',
            body: JSON.stringify(next),
        }).catch((e) => {
            setSaveError(e instanceof Error ? e : new Error(String(e)));
        });
    }, []);

    const providerValue = useMemo<FloorMapLayoutContextValue>(
        () => ({
            floorMapConfig: config,
            dataSources: {tables},
            onSave: handleSave,
            onDataSourceRecordSave: (key, id, partial) => {
                setTables((prev) =>
                    prev.map((row) => (row.id === id ? {...row, ...partial} : row))
                );
            },
            dataSourceLabels: {tables: '桌台'},
            loading,
            saveError,
        }),
        [config, tables, handleSave, loading, saveError]
    );

    return (
        <FloorMapLayoutProvider value={providerValue}>
            <div style={{height: '100vh'}}>
                <PisellFloorMapLayout
                    items={[]}
                    mode="edit"
                    mapLayer={{backgroundType: 'color', backgroundColor: '#f0f0f0', padding: 16}}
                    controls={{showFitBounds: true}}
                    renderItem={(item) => <span>{item.id}</span>}
                />
            </div>
        </FloorMapLayoutProvider>
    );
}
```

#### 示例二：props 覆盖 Context

同一 Provider 下，某一 Layout 需要**不同的** `loading` 等时，在 **`PisellFloorMapLayout`** 上显式传入即可（该字段优先）。

```tsx
import React, {useMemo} from 'react';
import {
    PisellFloorMapLayout,
    FloorMapLayoutProvider,
} from '@pisell/materials';
import type {FloorMapLayoutContextValue} from '@pisell/materials';

export default function FloorMapOverrideLoading() {
    const providerValue = useMemo<FloorMapLayoutContextValue>(
        () => ({loading: true}),
        []
    );
    return (
        <FloorMapLayoutProvider value={providerValue}>
            <PisellFloorMapLayout
                items={[{id: '1', x: 0, y: 0}]}
                renderItem={(item) => <span>{item.id}</span>}
                loading={false}
                mapLayer={{backgroundType: 'color', backgroundColor: '#f5f5f5', padding: 16}}
            />
        </FloorMapLayoutProvider>
    );
}
```

#### 示例三：深层节点读取 — `useFloorMapLayoutContext`

```tsx
import React from 'react';
import {useFloorMapLayoutContext} from '@pisell/materials';

export default function FloorMapCanvasTitle() {
    const ctx = useFloorMapLayoutContext();
    const name = ctx?.floorMapConfig?.canvases?.[0]?.name ?? '—';
    return <span>当前画布：{name}</span>;
}
```

无 Provider 时 **`useFloorMapLayoutContext()`** 返回 **`undefined`**，调用方需判空。该组件须渲染在 *
*`FloorMapLayoutProvider`** 子树下（RecordBoard 下即平面图区域所在子树）。

#### 示例四：PisellRecordBoard — 根级 `floorMapLayoutContext`（无需再包 Provider）

**`floorMap` / `PisellRecordBoard.FloorMap`** 写展示向配置；**`floorMapLayoutContext`** 写 **`floorMapConfig`**、*
*`onSave`**、侧栏表单等。Shell 内 **`GridLayout` / 列配置**
等见 [PisellRecordBoard 文档](../pisellRecordBoard/docs/PisellRecordBoard.md)。

```tsx
import React, {useCallback, useMemo, useState} from 'react';
import {PisellRecordBoard} from '@pisell/materials';
import type {FloorMapLayoutContextValue, FloorMapViewConfig} from '@pisell/materials';

export default function RecordBoardFloorMapContextDemo() {
    const [floorMapConfig, setFloorMapConfig] = useState<FloorMapViewConfig | undefined>();
    const handleSaveLayout = useCallback((config: FloorMapViewConfig) => {
        setFloorMapConfig(config);
    }, []);
    const handleSaveRecord = useCallback(
        (_dataSourceKey: string, _id: string, _patch: Record<string, unknown>) => {
        },
        []
    );

    const floorMapLayoutContext = useMemo<FloorMapLayoutContextValue>(
        () => ({
            floorMapConfig,
            onSave: handleSaveLayout,
            onDataSourceRecordSave: handleSaveRecord,
            loading: false,
            floorMapFullscreenMode: 'toggle',
        }),
        [floorMapConfig, handleSaveLayout, handleSaveRecord]
    );

    return (
        <PisellRecordBoard
            data={[]}
            dataSources={{tables: []}}
            pagination={{pageNumber: 1, pageSize: 10}}
            onPageChange={() => {
            }}
            searchParams={{}}
            onSearch={() => {
            }}
            floorMapLayoutContext={floorMapLayoutContext}
            floorMap={{
                mode: 'edit',
                mapLayer: {backgroundType: 'color', backgroundColor: '#f5f5f5', padding: 16},
                items: [],
                renderItem: () => null,
            }}
            grid={{
                columns: [{title: 'ID', dataIndex: 'id', key: 'id'}],
            }}
        >
            <PisellRecordBoard.ShellFrame>
                <PisellRecordBoard.GridLayout/>
                <PisellRecordBoard.FloorMap/>
            </PisellRecordBoard.ShellFrame>
        </PisellRecordBoard>
    );
}
```

**导出说明**（自 **`@pisell/materials`** 或包内 **`pisellFloorMapLayout`**）：**`FloorMapLayoutProvider`**、*
*`FloorMapLayoutProviderProps`**、**`useFloorMapLayoutContext`**、**`EMPTY_FLOOR_MAP_LAYOUT_CONTEXT`**、*
*`mergeFloorMapLayoutPropsFromContext`**、类型 **`FloorMapLayoutContextValue`**。详见下文 **API** 小节。

更多实现细节见 **`types.ts`** 与 [开发文档](./pisellFloorMapLayout.$tab-dev.md)。

**自定义记录表单最小示例**（自包含，需已安装 `antd`）：

```tsx
import React from 'react';
import {Form, Input, Button} from 'antd';
import type {FloorMapRecordFormRenderContext} from '@pisell/materials';

export default function buildSalesRecordForm() {
    return {
        sales: (ctx: FloorMapRecordFormRenderContext) => (
            <Form
                layout="vertical"
                size="small"
                initialValues={ctx.record}
                onFinish={(values) => ctx.onSaveRecord?.(values as Record<string, unknown>)}
            >
                <Form.Item name="name" label="名称">
                    <Input/>
                </Form.Item>
                <Button type="primary" htmlType="submit" loading={ctx.savingRecord} block size="small">
                    保存
                </Button>
            </Form>
        ),
    };
}

// 传入 PisellFloorMapLayout：dataSourceRecordForm={buildSalesRecordForm()}
```

## API

### PisellFloorMapLayout Props（基础）

| 参数               | 说明                                           | 类型                                                     | 默认值                  | 版本 |
|------------------|----------------------------------------------|--------------------------------------------------------|----------------------|----|
| items            | 资源列表；**平面图模式下由内部合并结果覆盖，可传 `[]` 占位**          | `T[]`                                                  | 必填                   | -  |
| renderItem       | 渲染每项内容；平面图下无 `renderItemByKind` 匹配时仍可能走到此项   | `(item, index, options?) => ReactNode`                 | 必填                   | -  |
| rowKey           | 列表 key，用于 React key                          | `string \| (item, index) => string`                    | `item.id`            | -  |
| renderItemByKind | **仅平面图**：按 `elementKind` 渲染，优先于 `renderItem` | `Record<string, (item, index, options?) => ReactNode>` | -                    | -  |
| mapLayer         | 底图层：背景、网格、padding、gap                        | `FloorMapLayerConfig`                                  | -                    | -  |
| cellSize         | 格子像素尺寸（cell 模式）；pixel 模式缺省宽高时用此值             | `number`                                               | `64`                 | -  |
| itemUnit         | 坐标与尺寸单位                                      | `'cell' \| 'pixel'`                                    | `'cell'`             | -  |
| zoom             | 缩放配置                                         | `FloorMapZoomConfig`                                   | -                    | -  |
| pan              | 平移配置                                         | `FloorMapPanConfig`                                    | `{ enabled: false }` | -  |
| controls         | 视图控制显隐                                       | `FloorMapControlsConfig`                               | 见下                   | -  |
| enableHover      | 是否开启 hover 高亮与事件                             | `boolean`                                              | `true`               | -  |
| onItemClick      | 项点击回调                                        | `(item, index, e) => void`                             | -                    | -  |
| onItemHover      | 项 hover 进入                                   | `(item, index) => void`                                | -                    | -  |
| onItemLeave      | 项 hover 离开                                   | `(item, index) => void`                                | -                    | -  |

### PisellFloorMapLayout Props（平面图 / 编辑）

以下在传入 **`floorMapConfig`** 时生效（具体类型见 `types.ts`）。

| 参数                        | 说明                                                                       | 类型                                                | 默认值        |
|---------------------------|--------------------------------------------------------------------------|---------------------------------------------------|------------|
| floorMapConfig            | 画布列表、`canvasUi`、`sceneElements`、`elementKindCategories`、`elementKinds` 等 | `FloorMapViewConfig`                              | -          |
| mode                      | `'read'` 只读；`'edit'` 可编辑（工具栏、侧栏、拖拽）                                      | `'read' \| 'edit'`                                | `'read'`   |
| dataSources               | 多数据源：`Record<key, Array<{ id, ... }>>`，落位 `dataSourceKey` + `id` 对应其中一行  | `FloorMapDataSources`                             | -          |
| onSave                    | 编辑态保存**布局草稿**                                                            | `(config: FloorMapViewConfig) => void`            | -          |
| onDataSourceRecordSave    | 侧栏保存**单条业务记录**                                                           | `(dataSourceKey, id, newData) => void`            | -          |
| dataSourceLabels          | 工具栏等处数据源展示名                                                              | `Record<string, string>`                          | -          |
| dataSourceFormColumns     | 内置 `FloorMapDataForm` 的列配置                                               | `Record<string, DataSourceFormColumn[]>`          | -          |
| dataSourceRecordForm      | **自定义**记录区：按 key 的 `ReactNode` 或 render prop，**优先生效**                    | `Record<string, ReactNode \| (ctx) => ReactNode>` | -          |
| getMergedItemWrapperStyle | 合并图元**外层定位容器**额外样式（如列表/搜索未命中时 `opacity`）；不影响子项内部布局                       | `(item, index) => CSSProperties \| undefined`     | -          |
| onElementRenderError      | `renderItemByKind` 抛错或绑定校验失败时回调（失败时画布上仍会显示错误占位）                          | `(element, error) => void`                        | -          |
| floorMapFullscreenMode    | 编辑态全屏 UI：`toggle`（默认，可进/退全屏）、`locked`（始终全屏顶栏、组件内不可退出）、`disabled`（无全屏按钮）  | `FloorMapFullscreenMode`                          | `'toggle'` |
| toolbarShowSave           | 是否在左侧工具栏显示保存（全屏或 `locked` 顶栏已含保存时侧栏保存会隐藏）                                | `boolean`                                         | `true`     |
| loading                   | 编辑/加载态                                                                   | `boolean`                                         | -          |
| saveError                 | 保存错误提示                                                                   | `Error \| string`                                 | -          |

上述字段也可通过 **`FloorMapLayoutContextValue`**（**`FloorMapLayoutProvider`** 或 RecordBoard **`floorMapLayoutContext`
**）注入，与 props 按 **`??`** 合并，**props 优先**。

### FloorMapLayoutProvider / Context（导出）

| 名称                                                    | 说明                                                                                                    |
|-------------------------------------------------------|-------------------------------------------------------------------------------------------------------|
| **`FloorMapLayoutProvider`**                          | 接收 **`value: FloorMapLayoutContextValue`** 与 **`children`**，注入 Context；**`value` 建议 `useMemo` 稳定引用**。 |
| **`FloorMapLayoutProviderProps`**                     | Provider 的 props 类型。                                                                                  |
| **`useFloorMapLayoutContext()`**                      | 返回当前 Context 或 **`undefined`**（无 Provider）。                                                           |
| **`EMPTY_FLOOR_MAP_LAYOUT_CONTEXT`**                  | 模块级稳定空对象；RecordBoard Shell 在未传 **`floorMapLayoutContext`** 时用作 Provider **`value`**。                  |
| **`mergeFloorMapLayoutPropsFromContext(props, ctx)`** | 将 Layout props 与 Context 按字段合并（与组件内部逻辑一致），供测试或二次封装。                                                   |
| **`FloorMapLayoutContextValue`**                      | Context 值类型，为 **`PisellFloorMapLayoutProps`** 中平面图相关字段的 **`Partial<Pick<...>>`**。                     |

#### `floorMapConfig.canvasUi`（画布编辑能力）

| 字段          | 说明                                             |
|-------------|------------------------------------------------|
| `canCreate` | 是否展示左侧调色板并允许「新建图元」落点模式。                        |
| `canEdit`   | 是否允许画布 Tab 增删改名、选中图元侧栏、`onSave` 保存布局、侧栏绑定与删除等。 |

从侧栏注入的 **`floorMapConfig`** 在运行时应包含 **`canvasUi`**。若仅兼容历史根级 `canCreate` / `canEdit`（无 `canvasUi`），
`useFloorMapEditState` 会在同步草稿时自动归并为 `canvasUi`。

### controls（视图控制）

| 参数             | 说明                                  | 类型        | 默认值     | 版本 |
|----------------|-------------------------------------|-----------|---------|----|
| showZoom       | 是否显示放大/缩小                           | `boolean` | `true`  | -  |
| showReset      | 是否显示复位                              | `boolean` | `false` | -  |
| showFitBounds  | 是否显示区域定位                            | `boolean` | `false` | -  |
| showFullscreen | 是否显示全屏按钮（点击后对根容器 requestFullscreen） | `boolean` | `false` | -  |

### mapLayer（底图层）

| 参数              | 说明                                           | 类型                       |
|-----------------|----------------------------------------------|--------------------------|
| backgroundType  | 背景类型：`'none' \| 'color' \| 'image' \| 'svg'` | `FloorMapBackgroundType` |
| backgroundColor | 纯色背景颜色（backgroundType 为 color 时）             | `string`                 |
| backgroundImage | 图片背景 URL（backgroundType 为 image 时）           | `string`                 |
| backgroundSvg   | SVG 背景内容或 URL（backgroundType 为 svg 时）        | `string`                 |
| showGrid        | 是否显示网格线                                      | `boolean`，默认 false       |
| padding         | 底层内边距（如 16 或 "16px"）                         | `number \| string`       |
| gap             | 格子/元素间距（如 8 或 "8px"）                         | `number \| string`       |

### zoom（缩放）

| 参数           | 说明                | 类型       | 默认  |
|--------------|-------------------|----------|-----|
| defaultScale | 默认缩放比例（1 = 100%）  | `number` | -   |
| min          | 最小缩放（如 0.5 = 50%） | `number` | 0.5 |
| max          | 最大缩放（如 2 = 200%）  | `number` | 2   |
| step         | 缩放步进（如 0.1 = 10%） | `number` | 0.1 |

### pan（平移）

| 参数       | 说明                                                                                                                           | 类型                    | 默认       |
|----------|------------------------------------------------------------------------------------------------------------------------------|-----------------------|----------|
| enabled  | 是否允许平移                                                                                                                       | `boolean`             | false    |
| boundary | 平移是否限制在**内容包盒**内：`'none'` 不限制（可一直拖动，类似无限画布）；`'clamp'` / `'bounce'` / **未传** 则限制（底层 `react-zoom-pan-pinch` 的 `limitToBounds`） | `FloorMapPanBoundary` | 未传时等同有边界 |

### 类型导出

可从 `@pisell/materials`（或包内 `pisellFloorMapLayout` 路径）获取：`PisellFloorMapLayoutProps`、`PisellFloorMapLayoutRef`、
`FloorMapItemBase`、`FloorMapMergedItem`、`FloorMapSceneElement`、`FloorMapViewConfig`、`FloorMapDataSources`、
`FloorMapElementKindConfig`、`FloorMapRecordFormRenderContext`、`FloorMapDataSourceRecordFormContent`、
`FloorMapFullscreenMode`、**`FloorMapLayoutContextValue`**、**`FloorMapLayoutProviderProps`**、
`FLOOR_MAP_STAGE_ELEMENT_KIND`，以及各类 Layer/Zoom/Pan/Controls/Shape 等配置类型（见 `types.ts` 与 `index.tsx` 导出列表）。

命名空间 **`PisellLayouts`** 下亦 re-export **`FloorMapLayoutProvider`**、**`useFloorMapLayoutContext`**、*
*`mergeFloorMapLayoutPropsFromContext`**、**`EMPTY_FLOOR_MAP_LAYOUT_CONTEXT`** 及上述类型（见 `PisellLayouts/index.tsx`）。

---

## Storybook 与 Figma 示例

组件提供多组 Story 用于调试与参考，其中 **FigmaDesign** 为与 Figma 设计稿一致的完整示例。

### 如何打开 Storybook

在仓库根目录或 `packages/materials` 下执行：

```bash
pnpm storybook
# 或
npm run storybook
```

在侧栏进入 **Data Display → PisellFloorMapLayout** 即可查看所有示例。

### Story 列表与说明

| Story 名称                             | 说明                                                                                   |
|--------------------------------------|--------------------------------------------------------------------------------------|
| **Default**                          | 基础渲染：5×4 格子布局、默认 controls（仅缩放）、简单格子样式。                                               |
| **WithGrid**                         | 开启底图网格线（`mapLayer.showGrid: true`）、平移。                                               |
| **WithPan**                          | 平移与视图控制条。                                                                            |
| **WithShapes**                       | 多种形状（矩形、圆、三角）与不同 width/height。                                                       |
| **WithVariableSizes**                | cell 模式下不同格子宽高（跨格）。                                                                  |
| **PixelUnits**                       | pixel 模式：自由像素位置与尺寸。                                                                  |
| **WithEvents**                       | 点击、hover、leave 事件与回调示例。                                                              |
| **WithRefControls**                  | 通过 ref 调用 zoomIn/zoomOut/reset/fitBounds，配合外部按钮。                                     |
| **ControlsHidden**                   | 隐藏视图控制（`controls.showZoom: false` 等）。                                                |
| **FloorMapWithLayoutProvider**       | **`FloorMapLayoutProvider`** 注入 `floorMapConfig` / `dataSources`，Layout 仅传展示向 props。 |
| **FloorMapViewEdit**                 | 平面图 **编辑态**：Tab、工具栏、侧栏、`mergeCanvasItems` + Figma 桌卡片。                               |
| **FloorMapViewRead**                 | 平面图 **只读**：无工具栏/侧栏。                                                                  |
| **FloorMapViewEditCustomRecordForm** | 编辑态 + **`dataSourceRecordForm.sales`** 自定义侧栏表单示例。                                    |
| **FigmaDesign**                      | **Figma 设计稿还原**：顶部 Tab + 图例 + 像素布局桌台图（见下）。                                           |

### Figma 完整示例代码（与 Storybook FigmaDesign 一致）

以下代码与 Storybook 中 **FigmaDesign** Story 完全一致，可直接复制到工程中运行（需已安装 `antd`、`@ant-design/icons`）。包含顶部
Tab、图例、像素布局桌台图及桌台状态卡片样式（Figma node 14131:6266 + 13998:648）。

```tsx
import React from 'react';
import {Tabs, Avatar} from 'antd';
import {
    UserOutlined,
    ClockCircleOutlined,
    TeamOutlined,
    AuditOutlined,
    FieldTimeOutlined,
} from '@ant-design/icons';
import {PisellFloorMapLayout} from '@pisell/materials';
import type {FloorMapItemBase} from '@pisell/materials';

const FIGMA = {
    white: '#FFFFFF',
    gray50: '#F9FAFB',
    gray200: '#EAECF0',
    gray400: '#98A2B3',
    gray500: '#667085',
    gray700: '#344054',
    gray900: '#101828',
    shadowSm: '0 1px 2px rgba(16, 24, 40, 0.06), 0 1px 3px rgba(16, 24, 40, 0.1)',
    fontFamily: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
    available: '#EAECF0',
    reserved: '#9254de',
    waiting: '#fa8c16',
    dining: '#1890ff',
    billed: '#52c41a',
    availableSoon: '#f5222d',
    barFillAvailable: '#EAECF0',
    legendReserved: '#D6BBFB',
    legendWaiting: '#FEC84B',
    legendDining: '#84CAFF',
    legendBilled: '#75E0A7',
    legendAvailableSoon: '#FDA29B',
} as const;

type TableStatus = 'available' | 'reserved' | 'waiting' | 'dining' | 'billed' | 'availableSoon';

interface FigmaTableItem extends FloorMapItemBase {
    status: TableStatus;
    customerName?: string;
    reserveTime?: string;
    guests?: number;
    dishes?: string;
    duration?: string;
}

const STATUS_BORDER: Record<TableStatus, string> = {
    available: FIGMA.gray200,
    reserved: FIGMA.reserved,
    waiting: FIGMA.waiting,
    dining: FIGMA.dining,
    billed: FIGMA.billed,
    availableSoon: FIGMA.availableSoon,
};

const GAP = 50;
const SMALL_W = 160;
const MED_W = 240;
const LARGE_W = 350;
const XL_W = 460;
const H = 160;

const figma13998PixelItems: FigmaTableItem[] = [
    {
        id: 'S-1',
        x: 0,
        y: 0,
        width: SMALL_W,
        height: H,
        status: 'reserved',
        customerName: 'Carolino',
        reserveTime: '13:00'
    },
    {id: 'S-2', x: SMALL_W + GAP, y: 0, width: SMALL_W, height: H, status: 'available'},
    {id: 'M-1', x: (SMALL_W + GAP) * 2, y: 0, width: MED_W, height: H, status: 'available'},
    {
        id: 'L-0',
        x: (SMALL_W + GAP) * 2 + MED_W + GAP,
        y: 0,
        width: LARGE_W,
        height: H,
        status: 'dining',
        guests: 4,
        dishes: '5/5 Dishes',
        duration: '18m20s'
    },
    {
        id: 'M-0',
        x: (SMALL_W + GAP) * 2 + MED_W + GAP + LARGE_W + GAP,
        y: 0,
        width: MED_W,
        height: H,
        status: 'available'
    },
    {
        id: 'S-3',
        x: 0,
        y: H + GAP,
        width: SMALL_W,
        height: H,
        status: 'availableSoon',
        guests: 7,
        dishes: '5/5 Dishes',
        duration: '35m30s'
    },
    {id: 'M-4', x: SMALL_W + GAP, y: H + GAP, width: MED_W, height: H, status: 'available'},
    {
        id: 'S-5',
        x: SMALL_W + GAP + MED_W + GAP,
        y: H + GAP,
        width: SMALL_W,
        height: H,
        status: 'dining',
        guests: 2,
        dishes: '3/5 Dishes',
        duration: '15m30s'
    },
    {
        id: 'S-6',
        x: SMALL_W + GAP + MED_W + GAP + SMALL_W + GAP,
        y: H + GAP,
        width: SMALL_W,
        height: H,
        status: 'reserved',
        customerName: 'Alex',
        reserveTime: '14:00'
    },
    {
        id: 'XL-0',
        x: (SMALL_W + GAP) * 2 + MED_W + GAP + SMALL_W + GAP,
        y: H + GAP,
        width: XL_W,
        height: H,
        status: 'available'
    },
    {id: 'L-1', x: 0, y: (H + GAP) * 2, width: MED_W, height: H, status: 'available'},
    {
        id: 'L-2',
        x: MED_W + GAP,
        y: (H + GAP) * 2,
        width: LARGE_W,
        height: H,
        status: 'billed',
        guests: 5,
        dishes: '5/5 Dishes',
        duration: '25m30s'
    },
    {id: 'L-5', x: MED_W + GAP + LARGE_W + GAP, y: (H + GAP) * 2, width: LARGE_W, height: H, status: 'available'},
    {
        id: 'S-7',
        x: MED_W + GAP + LARGE_W + GAP + LARGE_W + GAP,
        y: (H + GAP) * 2,
        width: SMALL_W,
        height: H,
        status: 'reserved',
        customerName: 'Carolino',
        reserveTime: '13:00'
    },
    {
        id: 'S-10',
        x: MED_W + GAP + LARGE_W + GAP + LARGE_W + GAP + SMALL_W + GAP,
        y: (H + GAP) * 2,
        width: SMALL_W,
        height: H,
        status: 'available'
    },
    {
        id: 'M-3',
        x: 0,
        y: (H + GAP) * 3,
        width: MED_W,
        height: H,
        status: 'waiting',
        guests: 2,
        dishes: '3/5 Dishes',
        duration: '15m30s'
    },
    {id: 'L-3', x: MED_W + GAP, y: (H + GAP) * 3, width: LARGE_W, height: H, status: 'available'},
    {
        id: 'S-8',
        x: MED_W + GAP + LARGE_W + GAP,
        y: (H + GAP) * 3,
        width: SMALL_W,
        height: H,
        status: 'dining',
        guests: 6,
        dishes: '4/5 Dishes',
        duration: '24m30s'
    },
    {
        id: 'M-5',
        x: (MED_W + GAP + LARGE_W + GAP) + SMALL_W + GAP,
        y: (H + GAP) * 3,
        width: MED_W,
        height: H,
        status: 'available'
    },
    {id: 'X-1', x: 0, y: (H + GAP) * 4, width: LARGE_W, height: H, status: 'available'},
    {id: 'S-9', x: LARGE_W + GAP, y: (H + GAP) * 4, width: SMALL_W, height: H, status: 'available'},
    {
        id: 'X-2',
        x: LARGE_W + GAP + SMALL_W + GAP,
        y: (H + GAP) * 4,
        width: LARGE_W,
        height: H,
        status: 'waiting',
        guests: 2,
        dishes: '3/5 Dishes',
        duration: '15m30s'
    },
    {
        id: 'L-4',
        x: LARGE_W + GAP + SMALL_W + GAP + LARGE_W + GAP,
        y: (H + GAP) * 4,
        width: LARGE_W,
        height: H,
        status: 'billed',
        guests: 5,
        dishes: '5/5 Dishes',
        duration: '25m30s'
    },
    {
        id: 'XL-1',
        x: 0,
        y: (H + GAP) * 5,
        width: XL_W,
        height: H,
        status: 'dining',
        guests: 12,
        dishes: '8/8 Dishes',
        duration: '45m00s'
    },
];

function FigmaCardRow({icon, children}: { icon: React.ReactNode; children: React.ReactNode }) {
    return (
        <div style={{
            display: 'flex',
            alignItems: 'center',
            gap: 4,
            fontFamily: FIGMA.fontFamily,
            fontSize: 14,
            lineHeight: '20px',
            fontWeight: 400,
            color: FIGMA.gray700,
            flexShrink: 0,
            minHeight: '20px'
        }}>
            <span style={{flexShrink: 0, color: FIGMA.gray400, fontSize: 14}}>{icon}</span>
            <span style={{flexShrink: 0}}>{children}</span>
        </div>
    );
}

function getBarCountFromItemId(id: string): number {
    const upper = id.toUpperCase();
    if (upper.startsWith('XL-')) return 4;
    if (upper.startsWith('L-') || upper.startsWith('X-')) return 3;
    if (upper.startsWith('M-')) return 2;
    return 1;
}

const CARD_BAR_WIDTH = 80;
const CARD_BAR_HEIGHT = 12;

function CardBars({count, position, barColor}: { count: number; position: 'top' | 'bottom'; barColor: string }) {
    return (
        <div style={{
            display: 'flex',
            flexDirection: 'row',
            gap: 30,
            justifyContent: 'center',
            flexShrink: 0,
            padding: position === 'top' ? '0 0 4px 0' : '4px 0 0 0'
        }}>
            {Array.from({length: count}, (_, i) => (
                <div key={i}
                     style={{width: CARD_BAR_WIDTH, height: CARD_BAR_HEIGHT, borderRadius: 6, background: barColor}}/>
            ))}
        </div>
    );
}

function renderFigmaTableCard(item: FigmaTableItem) {
    const status = item.status ?? 'available';
    const borderColor = STATUS_BORDER[status];
    const isAvailable = status === 'available';
    return (
        <div className="figma-table-card" style={{
            width: '100%',
            height: '100%',
            boxSizing: 'border-box',
            display: 'flex',
            flexDirection: 'column',
            justifyContent: 'flex-start',
            gap: 0,
            fontFamily: FIGMA.fontFamily,
            overflow: 'hidden'
        }}>
            <CardBars count={getBarCountFromItemId(item.id)} position="top"
                      barColor={isAvailable ? FIGMA.barFillAvailable : borderColor}/>
            <div style={{
                flex: 1,
                minHeight: 0,
                padding: 8,
                boxSizing: 'border-box',
                background: FIGMA.white,
                border: isAvailable ? `4px solid ${FIGMA.gray200}` : `4px solid ${borderColor}`,
                borderRadius: 8,
                boxShadow: FIGMA.shadowSm,
                display: 'flex',
                flexDirection: 'column',
                justifyContent: isAvailable ? 'flex-start' : 'center',
                alignItems: isAvailable ? 'stretch' : 'center',
                gap: 4
            }}>
                {isAvailable ? (
                    <div style={{
                        flex: 1,
                        display: 'flex',
                        alignItems: 'center',
                        justifyContent: 'center',
                        minHeight: 0,
                        fontWeight: 600,
                        fontSize: 18,
                        lineHeight: '24px',
                        color: FIGMA.gray400
                    }}>{item.id}</div>
                ) : (
                    <>
                        <div style={{
                            fontWeight: 600,
                            fontSize: 18,
                            lineHeight: '24px',
                            color: FIGMA.gray900,
                            textAlign: 'center'
                        }}>{item.id}</div>
                        {status === 'reserved' && (
                            <>
                                <FigmaCardRow icon={<Avatar size={16} icon={<UserOutlined/>}
                                                            style={{backgroundColor: FIGMA.gray200}}/>}><span>{item.customerName ?? ''}</span></FigmaCardRow>
                                <FigmaCardRow
                                    icon={<ClockCircleOutlined/>}><span>{item.reserveTime ?? ''}</span></FigmaCardRow>
                            </>
                        )}
                        {(status === 'dining' || status === 'waiting' || status === 'billed' || status === 'availableSoon') && item.guests != null && (
                            <>
                                <FigmaCardRow icon={<TeamOutlined/>}><span>{item.guests}</span></FigmaCardRow>
                                {item.dishes &&
                                    <FigmaCardRow icon={<AuditOutlined/>}><span>{item.dishes}</span></FigmaCardRow>}
                                {item.duration && <FigmaCardRow icon={<FieldTimeOutlined/>}><span>{item.duration}</span></FigmaCardRow>}
                            </>
                        )}
                    </>
                )}
            </div>
            <CardBars count={getBarCountFromItemId(item.id)} position="bottom"
                      barColor={isAvailable ? FIGMA.barFillAvailable : borderColor}/>
        </div>
    );
}

function LegendItem14131({color, label, fill = false}: { color: string; label: string; fill?: boolean }) {
    return (
        <span style={{
            display: 'inline-flex',
            alignItems: 'center',
            gap: 6,
            fontFamily: FIGMA.fontFamily,
            fontSize: 14,
            fontWeight: 500,
            lineHeight: '20px',
            color: '#000000'
        }}>
      <span style={{
          width: 16,
          height: 16,
          borderRadius: '50%',
          background: fill ? color : FIGMA.white,
          border: fill ? 'none' : `1px solid ${FIGMA.gray200}`,
          flexShrink: 0
      }}/>
            {label}
    </span>
    );
}

export default function FigmaDesignDemo() {
    return (
        <div style={{
            display: 'flex',
            flexDirection: 'column',
            gap: 20,
            background: FIGMA.gray50,
            padding: 24,
            borderRadius: 12,
            fontFamily: FIGMA.fontFamily
        }}>
            <div className="figma-story-header-14131" style={{
                display: 'flex',
                flexDirection: 'row',
                alignItems: 'center',
                justifyContent: 'space-between',
                flexWrap: 'nowrap',
                gap: 24
            }}>
                <div className="figma-story-tabs-wrap-14131" style={{
                    flexShrink: 0,
                    background: FIGMA.gray50,
                    border: `1px solid ${FIGMA.gray200}`,
                    padding: 6,
                    borderRadius: 8,
                    display: 'inline-flex',
                    gap: 8
                }}>
                    <Tabs defaultActiveKey="main" size="middle" style={{marginBottom: 0}}
                          items={[{key: 'main', label: 'Main Area'}, {
                              key: 'secondary',
                              label: 'Secondary Area'
                          }, {key: 'vip', label: 'VIP Area'}]} className="figma-story-tabs figma-story-tabs-14131"/>
                </div>
                <div className="figma-story-legend-14131" style={{
                    display: 'flex',
                    flexWrap: 'wrap',
                    gap: '12px 40px',
                    alignItems: 'center',
                    justifyContent: 'flex-end',
                    minWidth: 0
                }}>
                    <LegendItem14131 color={FIGMA.gray200} label="Available (4)" fill={false}/>
                    <LegendItem14131 color={FIGMA.legendReserved} label="Reserved (2)" fill/>
                    <LegendItem14131 color={FIGMA.legendWaiting} label="Waiting (3)" fill/>
                    <LegendItem14131 color={FIGMA.legendDining} label="Dining (1)" fill/>
                    <LegendItem14131 color={FIGMA.legendBilled} label="Billed (2)" fill/>
                    <LegendItem14131 color={FIGMA.legendAvailableSoon} label="Available Soon (1)" fill/>
                </div>
            </div>
            <div className="figma-story-map-wrap" style={{
                height: 520,
                background: FIGMA.white,
                border: `4px solid ${FIGMA.gray200}`,
                borderRadius: 8,
                overflow: 'hidden',
                boxShadow: FIGMA.shadowSm
            }}>
                <PisellFloorMapLayout className="figma-story-map" items={figma13998PixelItems}
                                      renderItem={(item) => renderFigmaTableCard(item as FigmaTableItem)}
                                      itemUnit="pixel" cellSize={64} mapLayer={{showGrid: false, padding: 20}}
                                      zoom={{defaultScale: 0.8, min: 0.5, max: 2}} pan={{enabled: true}}
                                      controls={{showZoom: true}} style={{height: '100%'}}/>
            </div>
            <style>{`
.figma-story-tabs-wrap-14131 .ant-tabs-nav { margin: 0; }
.figma-story-tabs-wrap-14131 .ant-tabs-nav-list { gap: 8px; }
.figma-story-tabs.figma-story-tabs-14131 .ant-tabs-tab { font-family: ${FIGMA.fontFamily}; padding: 10px 14px; margin: 0; font-size: 16px; font-weight: 600; line-height: 24px; color: ${FIGMA.gray500}; border-radius: 6px; }
.figma-story-tabs.figma-story-tabs-14131 .ant-tabs-tab.ant-tabs-tab-active { background: ${FIGMA.white}; box-shadow: ${FIGMA.shadowSm}; }
.figma-story-tabs.figma-story-tabs-14131 .ant-tabs-tab.ant-tabs-tab-active .ant-tabs-tab-btn { color: ${FIGMA.gray700}; }
.figma-story-tabs.figma-story-tabs-14131 .ant-tabs-ink-bar { display: none; }
.figma-story-tabs.figma-story-tabs-14131 .ant-tabs-nav::before { display: none; }
.figma-story-map-wrap [class*="react-transform-wrapper"], .figma-story-map-wrap [class*="react-transform-element"] { overflow: visible !important; }
.figma-story-map-wrap .pisell-floor-map-layout { overflow: visible !important; }
.figma-table-card, .figma-table-card * { visibility: visible !important; }
.figma-table-card { color: ${FIGMA.gray900}; }
.figma-table-card .ant-avatar { flex-shrink: 0; }
      `}</style>
        </div>
    );
}
```

### Figma 示例（FigmaDesign）说明

**FigmaDesign** Story 对应设计稿 **Figma node 14131:6266**（整体布局）与 **13998:648**（像素布局内容区），实现餐厅桌台状态图的完整效果。

**包含内容：**

1. **顶部区域（Figma 14131:6266）**

- **Tabs**：Main Area / Secondary Area / VIP Area，灰底容器、选中项白底+阴影、16px Semibold。
- **图例**：Available、Reserved、Waiting、Dining、Billed、Available Soon；16px 圆点（Available 为白底灰边，其余为实心填色）、14px
  Medium 黑字、横向 gap 12px 40px。

2. **内容区（楼层图）**

- 使用 **像素布局**（`itemUnit="pixel"`），桌台尺寸为：小格 160×160、中格 240×160、大格 350×160、XL 460×160，间距 50px。
- 每个桌台卡片根据状态（available / reserved / waiting / dining / billed /
  availableSoon）显示不同边框颜色与内容（预订客户/时间、人数、上菜进度、时长等）。
- 缩放控制位于**左下角**，仅显示放大/缩小；支持平移。

3. **如何查看**

- 在 Storybook 侧栏选择 **PisellFloorMapLayout → FigmaDesign**。
- 可缩放、拖拽画布，点击桌台查看交互；样式与 Figma 设计 token（颜色、字体、圆角、阴影）保持一致。

**设计稿链接**（设计文档中也有引用）：

- 默认规则排列：Figma node **14004:1168**
- 无规则像素布局：Figma node **13998:648**
- 视图控制位置：Figma node **14131:6265**
- 顶部 Tab 与图例：Figma node **14131:6266**

---

## 设计指南

详见 [设计文档](./pisellFloorMapLayout.$tab-design.md)

## 开发指南

详见 [开发文档](./pisellFloorMapLayout.$tab-dev.md)

## 测试指南

详见 [测试文档](./pisellFloorMapLayout.$tab-test.md)

## 后端接口

店铺端平面图 CRUD 与 `layout` 载荷说明见 [平面图编辑器 API](pisellFloorMapLayout.$tab-api.md)。
