{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "isometric-mark",
  "title": "等轴测标志",
  "description": "把任意平面多边形挤出成等轴测线条 3D，带斜纹顶面、跟随鼠标的线条高光与点击按下效果。",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "src/registry/ui/isometric-mark/isometric-mark.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  motion,\n  useInView,\n  useMotionValue,\n  useReducedMotion,\n  useSpring,\n  useTransform,\n  type MotionValue,\n} from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\n\nimport {\n  buildIsometricModel,\n  ISOMETRIC_ELEVATION,\n  rotateShapes,\n  wallPath,\n  type IsometricModel,\n  type IsometricPoint,\n  type IsometricShape,\n  type IsometricWall,\n} from \"./geometry\"\nimport { playPress, playRelease, useIsometricMarkMuted } from \"./sound\"\n\n/** 按下 / 弹起共用的弹簧，手感接近真实按键。 */\nconst PRESS_SPRING = { stiffness: 200, damping: 18, mass: 0.5 }\n/** 高光跟随鼠标的弹簧，比按下更“跟手”。 */\nconst LIGHT_SPRING = { stiffness: 300, damping: 30, mass: 0.1 }\n\ntype IsometricMarkProps = Omit<React.ComponentProps<\"svg\">, \"children\"> & {\n  /**\n   * 平面形状，坐标是平面 logo 自己的坐标（y 向下）。\n   * 请在组件外定义为常量，每次渲染传入新数组会重新计算几何。\n   */\n  shapes: readonly IsometricShape[]\n  /**\n   * 投影前绕图形中心旋转的角度（度），用来选择图形平躺在地上的朝向。\n   * 取 90 的整数倍时横竖边沿等轴测斜轴，像一块平放的板；斜向的图形可取任意角度，让长边顺着斜轴\n   * @default 0\n   */\n  rotation?: number\n  /**\n   * 视线的俯仰角（度，5–85）。调大更接近俯视（地面更方、厚度更薄），调小更接近平视（地面更扁、厚度更高）\n   * @default 35.26（标准等轴测）\n   */\n  elevation?: number\n  /** 挤出高度，单位与形状坐标相同；默认取图形平面尺寸的 12% */\n  depth?: number\n  /** 按下后的挤出高度，默认是 `depth` 的一半；`0` 为完全压平 */\n  pressedDepth?: number\n  /**\n   * 点击 / 触摸时是否有按下效果\n   * @default true\n   */\n  interactive?: boolean\n  /**\n   * 线条是否有跟随鼠标的高光；触屏与“减少动态效果”下自动关闭\n   * @default true\n   */\n  highlight?: boolean\n  /**\n   * 背景里的虚线辅助线。`true` 或 `\"box\"`：包围盒四边沿等轴测轴延长；\n   * `\"edges\"`：沿图形自己最长的两个边方向画外侧切线（适合斜向的图形，辅助线贴着长边与齐平的端点）\n   * @default false\n   */\n  guides?: boolean | \"box\" | \"edges\"\n  /**\n   * 顶面材质：`hatch` 斜纹「/」、`hatch-reverse` 斜纹「\\」、`cross` 交叉网纹、`dots` 点阵、`lines` 横纹、`plain` 素面。\n   * 斜纹与图形的长边接近平行时会产生视差感，换成另一个方向或横纹即可\n   * @default \"hatch\"\n   */\n  material?: IsometricMaterial\n  /**\n   * 图形下方的地面，与辅助线同方向、同平面、对齐每条边，向四周渐隐。\n   * `true` / `\"grid\"` 方格网、`\"lines\"` 只有一个方向的条纹、`\"dots\"` 站点的点阵底纹（屏幕平面，8px 间距）、`\"checker\"` 棋盘格\n   * @default false\n   */\n  floor?: boolean | IsometricFloor\n  /**\n   * 按下与松开时播放合成音效（Web Audio，无需音频文件）；尊重全站静音偏好，见 useIsometricMarkMuted\n   * @default false\n   */\n  sound?: boolean\n  /**\n   * 音色：0 偏闷（低沉的「咚」），1 偏脆（清脆的「咔」）\n   * @default 0.6\n   */\n  soundTone?: number\n  /** 可访问名称；不传时整个图形对读屏隐藏 */\n  label?: string\n}\n\n/** 单面侧墙：高度由弹簧驱动，墙路径随高度重算，顶边始终贴着顶面。 */\nfunction IsometricMarkWall({\n  wall,\n  height,\n  gradientId,\n  heightScale,\n}: {\n  wall: IsometricWall\n  height: MotionValue<number>\n  gradientId: string\n  heightScale: number\n}) {\n  const d = useTransform(height, (h) => wallPath(wall, h, heightScale))\n\n  // 同一面先画底色线再画高光线，后画的近处面会盖住远处面的线；有色调时墙面极淡着色、线条取同色\n  return (\n    <g\n      data-slot=\"isometric-mark-wall\"\n      style={\n        wall.tone\n          ? ({\n              \"--wall-fill\": `color-mix(in oklab, ${wall.tone} var(--tone-wall), var(--wall))`,\n              \"--wall-stroke\": `color-mix(in oklab, ${wall.tone} var(--tone-line), var(--background))`,\n            } as React.CSSProperties)\n          : undefined\n      }\n    >\n      <motion.path\n        d={d}\n        fill=\"var(--wall-fill, var(--wall))\"\n        stroke=\"var(--wall-stroke, var(--stroke))\"\n      />\n      <motion.path d={d} fill=\"none\" stroke={`url(#${gradientId})`} />\n    </g>\n  )\n}\n\ntype IsometricMarkTuning = Partial<\n  Pick<\n    IsometricMarkProps,\n    | \"rotation\"\n    | \"depth\"\n    | \"elevation\"\n    | \"guides\"\n    | \"material\"\n    | \"interactive\"\n    | \"highlight\"\n    | \"sound\"\n    | \"soundTone\"\n    | \"floor\"\n  >\n> & {\n  /** 固定画框：沿用原属性下的 viewBox，调整时容器大小与图形比例都不变 */\n  stableFrame?: boolean\n  /** 回报示例自己的实际取值（未被覆盖前，含自动高度与默认俯仰），面板据此显示当前值 */\n  onReport?: (values: IsometricMarkReport) => void\n}\n\ntype IsometricMarkReport = {\n  rotation: number\n  elevation: number\n  depth: number\n  guides: \"box\" | \"edges\" | false\n  material: IsometricMaterial\n  interactive: boolean\n  highlight: boolean\n  sound: boolean\n  soundTone: number\n  floor: IsometricFloor | false\n}\n\n/**\n * 可选的调整上下文：子树里所有 IsometricMark 的对应属性被这里的值覆盖。\n * 用于调试面板一类的场景；不包这一层时组件行为不变。\n */\nconst IsometricMarkTuningContext = React.createContext<IsometricMarkTuning>({})\n\ntype IsometricFloor = \"grid\" | \"lines\" | \"dots\" | \"checker\"\n\ntype IsometricMaterial =\n  \"hatch\" | \"hatch-reverse\" | \"cross\" | \"dots\" | \"lines\" | \"plain\"\n\n/** 各材质在一个 size×size 平铺单元里的路径（u 为一个像素对应的长度） */\nfunction materialPath(material: IsometricMaterial, size: number, u: number) {\n  const slash = `M${-u} ${u}l${u * 2} ${-u * 2}M0 ${size}l${size} ${-size}M${size - u} ${size + u}l${u * 2} ${-u * 2}`\n  const back = `M${-u} ${size - u}l${u * 2} ${u * 2}M0 0l${size} ${size}M${size - u} ${-u}l${u * 2} ${u * 2}`\n  switch (material) {\n    case \"hatch\":\n      return slash\n    case \"hatch-reverse\":\n      return back\n    case \"cross\":\n      return slash + back\n    case \"lines\":\n      return `M0 ${size / 2}H${size}`\n    default:\n      return \"\"\n  }\n}\n\n/** 把平面多边形挤出成等轴测线条 3D，带斜纹顶面、跟随鼠标的高光与按下效果。 */\nfunction IsometricMark(allProps: IsometricMarkProps) {\n  const tuning = React.useContext(IsometricMarkTuningContext)\n  const { stableFrame, onReport, ...overrides } = tuning\n  // 固定画框：沿用按原属性算出的 viewBox，调整时画框与图形比例都不变，\n  // 容器既不长高也不把图形挤小；转到画框外的部分照常显示（svg overflow: visible）\n  const {\n    shapes,\n    rotation = 0,\n    depth,\n    guides = false,\n    elevation,\n    material = \"hatch\",\n    interactive = true,\n    highlight = true,\n    sound = false,\n    soundTone = 0.6,\n    floor = false,\n  } = allProps\n  const baseModel = React.useMemo(\n    () =>\n      stableFrame || onReport\n        ? buildIsometricModel(\n            rotateShapes(shapes, rotation),\n            depth,\n            guides === \"edges\" ? \"edges\" : \"box\",\n            elevation,\n          )\n        : undefined,\n    [stableFrame, onReport, shapes, rotation, depth, guides, elevation],\n  )\n  const fixedViewBox = stableFrame ? baseModel?.viewBox : undefined\n  React.useEffect(() => {\n    if (!onReport || !baseModel) return\n    onReport({\n      rotation,\n      elevation: elevation ?? ISOMETRIC_ELEVATION,\n      depth: baseModel.depth,\n      guides: guides === true ? \"box\" : guides,\n      material,\n      interactive,\n      highlight,\n      sound,\n      soundTone,\n      floor: floor === true ? \"grid\" : floor,\n    })\n  }, [\n    onReport,\n    baseModel,\n    rotation,\n    elevation,\n    guides,\n    material,\n    interactive,\n    highlight,\n    sound,\n    soundTone,\n    floor,\n  ])\n  return (\n    <IsometricMarkView\n      {...allProps}\n      {...overrides}\n      fixedViewBox={fixedViewBox}\n    />\n  )\n}\n\nfunction IsometricMarkView({\n  shapes,\n  rotation = 0,\n  depth: depthProp,\n  pressedDepth,\n  interactive = true,\n  highlight = true,\n  guides = false,\n  material = \"hatch\",\n  elevation,\n  floor = false,\n  sound = false,\n  soundTone = 0.6,\n  label,\n  className,\n  ref,\n  onPointerDown,\n  onPointerUp,\n  onPointerCancel,\n  onPointerLeave,\n  fixedViewBox,\n  ...props\n}: IsometricMarkProps & { fixedViewBox?: IsometricModel[\"viewBox\"] }) {\n  const id = React.useId()\n  const patternId = `isometric-mark-pattern-${id}`\n  const gradientId = `isometric-mark-light-${id}`\n\n  const model = React.useMemo(\n    () =>\n      buildIsometricModel(\n        rotateShapes(shapes, rotation),\n        depthProp,\n        guides === \"edges\" ? \"edges\" : \"box\",\n        elevation,\n      ),\n    [shapes, rotation, depthProp, guides, elevation],\n  )\n  const { unit, depth } = model\n  const viewBox = fixedViewBox ?? model.viewBox\n  const pressedHeight = pressedDepth ?? depth / 2\n\n  const rootRef = React.useRef<SVGSVGElement | null>(null)\n  const setRef = React.useCallback(\n    (node: SVGSVGElement | null) => {\n      rootRef.current = node\n      if (typeof ref === \"function\") ref(node)\n      else if (ref) ref.current = node\n    },\n    [ref],\n  )\n\n  const reduceMotion = useReducedMotion()\n  const inView = useInView(rootRef, { margin: \"80px\" })\n\n  // 当前挤出高度；顶面整体下移 depth - height，侧墙按 height 重算\n  const [pressed, setPressed] = React.useState(false)\n  const height = useSpring(depth, PRESS_SPRING)\n  React.useEffect(() => {\n    const target = pressed ? pressedHeight : depth\n    if (reduceMotion) height.jump(target)\n    else height.set(target)\n  }, [pressed, pressedHeight, depth, reduceMotion, height])\n  // 用 y 偏移而不是 transform 字符串，斜纹才会跟着轮廓一起走\n  const topOffset = useTransform(height, (h) => (depth - h) * model.heightScale)\n\n  // 高光：鼠标在窗口中的相对位置映射到 viewBox\n  const mouseX = useMotionValue(0.5)\n  const mouseY = useMotionValue(0.5)\n  const lightX = useSpring(\n    useTransform(mouseX, [0, 1], [viewBox.x, viewBox.x + viewBox.width]),\n    LIGHT_SPRING,\n  )\n  const lightY = useSpring(\n    useTransform(mouseY, [0, 1], [viewBox.y, viewBox.y + viewBox.height]),\n    LIGHT_SPRING,\n  )\n  React.useEffect(() => {\n    if (!highlight || reduceMotion || !inView) return\n    if (window.matchMedia(\"(hover: none)\").matches) return\n\n    const handleMouseMove = (event: MouseEvent) => {\n      mouseX.set(event.clientX / window.innerWidth)\n      mouseY.set(event.clientY / window.innerHeight)\n    }\n    window.addEventListener(\"mousemove\", handleMouseMove, { passive: true })\n    return () => window.removeEventListener(\"mousemove\", handleMouseMove)\n  }, [highlight, reduceMotion, inView, mouseX, mouseY])\n\n  const [muted] = useIsometricMarkMuted()\n  const audible = sound && !muted\n  const pressedRef = React.useRef(false)\n  // 点阵地面要和站点的 bg-dots 一样按屏幕像素算（8px 间距、1px 方点），所以量出 1px 对应多少 viewBox 单位\n  const [pixel, setPixel] = React.useState(unit * 2)\n  React.useEffect(() => {\n    const svg = rootRef.current\n    if (floor !== \"dots\" || !svg) return\n    const update = () => {\n      const width = svg.getBoundingClientRect().width\n      if (width > 0) setPixel(viewBox.width / width)\n    }\n    update()\n    const observer = new ResizeObserver(update)\n    observer.observe(svg)\n    return () => observer.disconnect()\n  }, [floor, viewBox.width])\n\n  // 各种地面的浓淡：格点面积小要加重，棋盘格是整块填色要减轻\n  const floorStrength =\n    floor === \"dots\"\n      ? 3.2\n      : floor === \"checker\"\n        ? 0.45\n        : floor === \"lines\"\n          ? 1.3\n          : 1\n  const release = () => {\n    if (pressedRef.current && audible) playRelease(soundTone)\n    pressedRef.current = false\n    setPressed(false)\n  }\n  const hatch = unit * 10\n\n  return (\n    <svg\n      ref={setRef}\n      data-slot=\"isometric-mark\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      viewBox={`${viewBox.x} ${viewBox.y} ${viewBox.width} ${viewBox.height}`}\n      fill=\"none\"\n      role={label ? \"img\" : undefined}\n      aria-label={label}\n      aria-hidden={label ? undefined : true}\n      className={cn(\n        \"block h-auto w-full touch-manipulation overflow-visible select-none\",\n        // 颜色变量：顶面、侧墙、纹理、线条、着色比例。暗色下背景接近黑色，按同样比例混色几乎看不见，\n        // 所以单独提亮：顶面比背景亮一档、侧墙再暗一点形成体积，纹理与线条加重，着色比例加大\n        \"[--guide:color-mix(in_oklab,var(--foreground)_14%,transparent)] [--pattern:color-mix(in_oklab,var(--foreground)_12%,var(--background))] [--stroke:color-mix(in_oklab,var(--foreground)_16%,var(--background))] [--tone-line:60%] [--tone-top:16%] [--tone-wall:9%] [--top:var(--background)] [--wall:var(--background)]\",\n        \"dark:[--guide:color-mix(in_oklab,var(--foreground)_22%,transparent)] dark:[--pattern:color-mix(in_oklab,var(--foreground)_24%,var(--background))] dark:[--stroke:color-mix(in_oklab,var(--foreground)_38%,var(--background))] dark:[--tone-line:80%] dark:[--tone-top:30%] dark:[--tone-wall:18%] dark:[--top:color-mix(in_oklab,var(--foreground)_9%,var(--background))] dark:[--wall:color-mix(in_oklab,var(--foreground)_4%,var(--background))]\",\n        className,\n      )}\n      onPointerDown={(event) => {\n        onPointerDown?.(event)\n        if (!interactive) return\n        // 按住压下时别把旁边的文字一起选中（拖动或双击会从这里开始选区）\n        if (event.pointerType === \"mouse\") event.preventDefault()\n        if (audible) playPress(soundTone)\n        pressedRef.current = true\n        setPressed(true)\n      }}\n      onPointerUp={(event) => {\n        onPointerUp?.(event)\n        release()\n      }}\n      onPointerCancel={(event) => {\n        onPointerCancel?.(event)\n        release()\n      }}\n      onPointerLeave={(event) => {\n        onPointerLeave?.(event)\n        release()\n      }}\n      {...props}\n    >\n      <defs>\n        <pattern\n          id={patternId}\n          width={hatch}\n          height={hatch}\n          patternUnits=\"userSpaceOnUse\"\n        >\n          {material === \"dots\" ? (\n            <circle\n              cx={hatch / 2}\n              cy={hatch / 2}\n              r={unit * 1.1}\n              fill=\"var(--pattern)\"\n            />\n          ) : (\n            <path\n              d={materialPath(material, hatch, unit)}\n              stroke=\"var(--pattern)\"\n              strokeWidth={unit}\n            />\n          )}\n        </pattern>\n\n        <motion.radialGradient\n          id={gradientId}\n          cx={lightX}\n          cy={lightY}\n          r={unit * 200}\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"var(--foreground)\" stopOpacity=\"0.7\" />\n          <stop offset=\"1\" stopColor=\"var(--foreground)\" stopOpacity=\"0\" />\n        </motion.radialGradient>\n      </defs>\n\n      {floor ? (\n        <>\n          {/* 地面网格直接用径向渐变描边：中心清晰、向外淡出，不影响图形本身 */}\n          <radialGradient\n            id={`${patternId}-floor`}\n            cx={viewBox.x + viewBox.width / 2}\n            cy={viewBox.y + viewBox.height / 2}\n            r={Math.max(viewBox.width, viewBox.height) * 0.7}\n            gradientUnits=\"userSpaceOnUse\"\n          >\n            <stop\n              offset=\"0\"\n              style={{\n                stopColor: \"var(--foreground)\",\n                stopOpacity: 0.1 * floorStrength,\n              }}\n            />\n            <stop\n              offset=\"0.55\"\n              style={{\n                stopColor: \"var(--foreground)\",\n                stopOpacity: 0.07 * floorStrength,\n              }}\n            />\n            <stop\n              offset=\"1\"\n              style={{ stopColor: \"var(--foreground)\", stopOpacity: 0 }}\n            />\n          </radialGradient>\n          {floor === \"dots\" ? (\n            // 格点地面用站点的点阵底纹：8px 间距、1px 方点、前景色 34%（同 grid.css 的 bg-dots），\n            // 铺在屏幕平面上，用径向渐变遮罩向四周淡出\n            <>\n              <pattern\n                id={`${patternId}-dots`}\n                width={pixel * 8}\n                height={pixel * 8}\n                patternUnits=\"userSpaceOnUse\"\n              >\n                <rect\n                  x={pixel * 4}\n                  y={pixel * 4}\n                  width={pixel}\n                  height={pixel}\n                  fill=\"color-mix(in oklab, var(--foreground) 34%, transparent)\"\n                />\n              </pattern>\n              <mask\n                id={`${patternId}-dots-mask`}\n                maskUnits=\"userSpaceOnUse\"\n                x={viewBox.x - viewBox.width * 2}\n                y={viewBox.y - viewBox.height * 2}\n                width={viewBox.width * 5}\n                height={viewBox.height * 5}\n              >\n                <rect\n                  x={viewBox.x - viewBox.width * 2}\n                  y={viewBox.y - viewBox.height * 2}\n                  width={viewBox.width * 5}\n                  height={viewBox.height * 5}\n                  fill={`url(#${patternId}-dots-fade)`}\n                />\n              </mask>\n              <radialGradient\n                id={`${patternId}-dots-fade`}\n                cx={viewBox.x + viewBox.width / 2}\n                cy={viewBox.y + viewBox.height / 2}\n                r={Math.max(viewBox.width, viewBox.height) * 1.5}\n                gradientUnits=\"userSpaceOnUse\"\n              >\n                <stop offset=\"0.3\" stopColor=\"white\" />\n                <stop offset=\"1\" stopColor=\"white\" stopOpacity=\"0\" />\n              </radialGradient>\n              <rect\n                data-slot=\"isometric-mark-floor\"\n                data-floor=\"dots\"\n                x={viewBox.x - viewBox.width * 2}\n                y={viewBox.y - viewBox.height * 2}\n                width={viewBox.width * 5}\n                height={viewBox.height * 5}\n                fill={`url(#${patternId}-dots)`}\n                mask={`url(#${patternId}-dots-mask)`}\n              />\n            </>\n          ) : floor === \"checker\" ? (\n            <path\n              data-slot=\"isometric-mark-floor\"\n              data-floor=\"checker\"\n              d={model.floorCells}\n              fill={`url(#${patternId}-floor)`}\n            />\n          ) : (\n            <path\n              data-slot=\"isometric-mark-floor\"\n              data-floor={floor === \"lines\" ? \"lines\" : \"grid\"}\n              d={floor === \"lines\" ? model.floorLines : model.floor}\n              stroke={`url(#${patternId}-floor)`}\n              strokeWidth={floor === \"lines\" ? unit * 1.5 : unit}\n            />\n          )}\n        </>\n      ) : null}\n\n      {guides ? (\n        <path\n          data-slot=\"isometric-mark-guides\"\n          d={model.guides}\n          stroke=\"var(--guide)\"\n          strokeWidth={unit}\n          strokeDasharray={`${unit * 4} ${unit * 2}`}\n        />\n      ) : null}\n\n      <g\n        strokeWidth={unit}\n        strokeLinejoin=\"round\"\n        strokeLinecap=\"round\"\n        fillRule=\"evenodd\"\n      >\n        {model.walls.map((wall, index) => (\n          <IsometricMarkWall\n            key={index}\n            wall={wall}\n            height={height}\n            gradientId={gradientId}\n            heightScale={model.heightScale}\n          />\n        ))}\n\n        <motion.g data-slot=\"isometric-mark-top\" style={{ y: topOffset }}>\n          {model.tops.some((top) => top.tone) ? (\n            // 有色调的形状逐块画：淡色底 + 同一张材质纹理 + 同色描边\n            model.tops.map((top, index) => (\n              <g key={index}>\n                <path\n                  d={top.d}\n                  fill={\n                    top.tone\n                      ? `color-mix(in oklab, ${top.tone} var(--tone-top), var(--top))`\n                      : \"var(--top)\"\n                  }\n                />\n                <path d={top.d} fill={`url(#${patternId})`} />\n                <path\n                  d={top.d}\n                  stroke={\n                    top.tone\n                      ? `color-mix(in oklab, ${top.tone} var(--tone-line), var(--background))`\n                      : \"var(--stroke)\"\n                  }\n                />\n              </g>\n            ))\n          ) : (\n            <>\n              <path d={model.top} fill=\"var(--top)\" />\n              <path d={model.top} fill={`url(#${patternId})`} />\n              <path d={model.top} stroke=\"var(--stroke)\" />\n            </>\n          )}\n          <path d={model.top} stroke={`url(#${gradientId})`} />\n        </motion.g>\n      </g>\n    </svg>\n  )\n}\n\nexport { IsometricMark, IsometricMarkTuningContext, useIsometricMarkMuted }\nexport type {\n  IsometricMarkProps,\n  IsometricMarkReport,\n  IsometricMarkTuning,\n  IsometricFloor,\n  IsometricMaterial,\n  IsometricPoint,\n  IsometricShape,\n}\n",
      "type": "registry:ui",
      "target": "@ui/isometric-mark/isometric-mark.tsx"
    },
    {
      "path": "src/registry/ui/isometric-mark/geometry.ts",
      "content": "/**\n * 等轴测挤出的纯几何计算：只依赖数学，不碰 React，方便单测与复用。\n *\n * 坐标约定：平面 logo 的坐标 (x, y)，y 向下；挤出方向为 z（向上）。\n * 投影：X = (x - y)·cos30°，Y = (x + y)·sin30° - z。\n * 在这个投影下，观察者位于 (+x, +y, +z) 一侧，朝向 (1, 1) 的墙面可见。\n */\n\n/** 平面坐标点 `[x, y]` */\ntype IsometricPoint = readonly [number, number]\n\n/** 一个平面形状：一圈外轮廓加若干个孔，孔会被挤成通孔。 */\ntype IsometricShape = {\n  outer: readonly IsometricPoint[]\n  holes?: readonly (readonly IsometricPoint[])[]\n  /** 这块形状的色调（任意 CSS 颜色）：顶面淡淡着色、线条取同色；不传为单色线稿 */\n  tone?: string\n}\n\n/** 墙面底边在屏幕上的两个端点（z = 0），顶边由当前高度决定。 */\ntype IsometricWall = {\n  x0: number\n  y0: number\n  x1: number\n  y1: number\n  tone?: string\n}\n\ntype IsometricModel = {\n  viewBox: { x: number; y: number; width: number; height: number }\n  /** 线宽、斜纹、虚线等“一个像素”对应的 viewBox 长度，随图形尺寸等比缩放 */\n  unit: number\n  /** 实际使用的挤出高度（props 未传时为自动值） */\n  depth: number\n  /** 顶面路径（z = depth），外轮廓与孔都在其中，需配合 evenodd */\n  top: string\n  /** 按形状拆开的顶面，带各自的色调 */\n  tops: { d: string; tone?: string }[]\n  /** 可见侧墙，按从远到近排好序 */\n  walls: IsometricWall[]\n  /** 背景辅助线路径（z = 0） */\n  guides: string\n  /** 地面网格路径（z = 0），与辅助线同方向 */\n  floor: string\n  /** 只有第一个方向的地面横线 */\n  floorLines: string\n  /** 地面格点（两组格线的交点） */\n  floorDots: string\n  /** 棋盘格：隔一格填一格 */\n  floorCells: string\n  /** 挤出高度 1 在屏幕上对应的竖直长度（随俯仰角变化，标准等轴测为 1） */\n  heightScale: number\n}\n\nconst COS30 = Math.cos(Math.PI / 6)\nconst SIN30 = 0.5\nconst EPSILON = 1e-6\n/** 自动挤出高度占图形平面尺寸的比例 */\nconst AUTO_DEPTH_RATIO = 0.12\n\nconst fmt = (n: number) => String(Math.round(n * 100) / 100)\n\n/** 标准等轴测的俯仰角：视线与地面成 arctan(1/√2) ≈ 35.26° */\nconst ISOMETRIC_ELEVATION = (Math.atan(1 / Math.SQRT2) * 180) / Math.PI\n\n/**\n * 投影系数：水平方位固定 45°（两条地面轴左右对称），俯仰角可调。\n * 地面方向的竖直压缩 ky = sinE·√2·cos30°，高度方向 kz = cosE·√2·cos30°；\n * 俯仰角取 35.26° 时 ky = 0.5、kz = 1，正好是标准等轴测。\n * 俯仰角越大越接近俯视（地面越“方”、厚度越薄），越小越接近平视（地面越扁、厚度越高）。\n */\nlet KY = SIN30\nlet KZ = 1\n\nfunction setElevation(degrees: number) {\n  const e = (Math.min(Math.max(degrees, 5), 85) * Math.PI) / 180\n  KY = Math.sin(e) * Math.SQRT2 * COS30\n  KZ = Math.cos(e) * Math.SQRT2 * COS30\n}\n\nfunction project(x: number, y: number, z = 0): [number, number] {\n  return [(x - y) * COS30, (x + y) * KY - z * KZ]\n}\n\n/** 去掉首尾重复的闭合点，统一成不含重复端点的环。 */\nfunction cleanRing(ring: readonly IsometricPoint[]): IsometricPoint[] {\n  const points = [...ring]\n  const first = points[0]\n  const last = points[points.length - 1]\n  if (\n    points.length > 1 &&\n    first &&\n    last &&\n    first[0] === last[0] &&\n    first[1] === last[1]\n  ) {\n    points.pop()\n  }\n  return points\n}\n\nfunction signedArea(ring: readonly IsometricPoint[]) {\n  let sum = 0\n  for (let i = 0; i < ring.length; i++) {\n    const [x0, y0] = ring[i]!\n    const [x1, y1] = ring[(i + 1) % ring.length]!\n    sum += x0 * y1 - x1 * y0\n  }\n  return sum / 2\n}\n\n/** 外轮廓取正面积、孔取负面积，这样所有环的“实体外侧”法线公式一致。 */\nfunction orient(ring: IsometricPoint[], positive: boolean) {\n  return signedArea(ring) > 0 === positive ? ring : [...ring].reverse()\n}\n\nfunction ringPath(ring: readonly IsometricPoint[], z: number) {\n  return (\n    ring\n      .map(([x, y], i) => {\n        const [px, py] = project(x, y, z)\n        return `${i === 0 ? \"M\" : \"L\"}${fmt(px)} ${fmt(py)}`\n      })\n      .join(\"\") + \"Z\"\n  )\n}\n\ntype Segment = {\n  /** 平面坐标，用于排序 */\n  px: number\n  py: number\n  qx: number\n  qy: number\n  tone?: string\n}\n\n/** 排序键：沿观察方向的“深度”，越大越靠近观察者。 */\nconst depthOf = (s: Segment) => (s.px + s.py + s.qx + s.qy) / 4\n\n/**\n * 判断两面墙的前后关系：-1 表示 a 在 b 后面（先画 a），1 相反，0 表示互不遮挡。\n * 墙在屏幕上只有 X = x - y 方向的范围，范围不重叠就不会互相遮挡；\n * 重叠区间内两线段不相交，因此取区间中点比较 x + y 即可。\n */\nfunction compareSegments(a: Segment, b: Segment) {\n  const ua0 = a.px - a.py\n  const ua1 = a.qx - a.qy\n  const ub0 = b.px - b.py\n  const ub1 = b.qx - b.qy\n  const lo = Math.max(Math.min(ua0, ua1), Math.min(ub0, ub1))\n  const hi = Math.min(Math.max(ua0, ua1), Math.max(ub0, ub1))\n  if (hi - lo < EPSILON) return 0\n\n  const u = (lo + hi) / 2\n  const depthAt = (s: Segment, u0: number, u1: number) => {\n    const t = (u - u0) / (u1 - u0)\n    return s.px + s.py + t * (s.qx + s.qy - s.px - s.py)\n  }\n  const diff = depthAt(a, ua0, ua1) - depthAt(b, ub0, ub1)\n  if (Math.abs(diff) < EPSILON) return 0\n  return diff < 0 ? -1 : 1\n}\n\n/**\n * 画家算法的顺序：先按“谁挡住谁”做拓扑排序，凹多边形也能排对；\n * 理论上不会出现环，真遇到时退回按深度最小者优先，保证一定有结果。\n * 顺序只取决于平面形状，与挤出高度无关，所以按下动画时不用重排。\n */\nfunction sortBackToFront(segments: Segment[]) {\n  type OrderingNode = {\n    segment: Segment\n    after: OrderingNode[]\n    blockers: number\n    done: boolean\n  }\n  const nodes = segments.map((segment): OrderingNode => ({\n    segment,\n    after: [],\n    blockers: 0,\n    done: false,\n  }))\n  for (const [i, node] of nodes.entries()) {\n    for (let j = i + 1; j < nodes.length; j++) {\n      // j 由同一数组的长度约束。\n      const other = nodes[j]!\n      const order = compareSegments(node.segment, other.segment)\n      if (order < 0) {\n        node.after.push(other)\n        other.blockers++\n      } else if (order > 0) {\n        other.after.push(node)\n        node.blockers++\n      }\n    }\n  }\n  const sorted: Segment[] = []\n  for (let k = 0; k < nodes.length; k++) {\n    let pick: OrderingNode | undefined\n    for (const node of nodes) {\n      if (node.done || node.blockers > 0) continue\n      if (!pick || depthOf(node.segment) < depthOf(pick.segment)) pick = node\n    }\n    if (!pick) {\n      for (const node of nodes) {\n        if (node.done) continue\n        if (!pick || depthOf(node.segment) < depthOf(pick.segment)) pick = node\n      }\n    }\n    if (!pick) break\n    pick.done = true\n    sorted.push(pick.segment)\n    for (const next of pick.after) next.blockers--\n  }\n  return sorted\n}\n\nfunction buildIsometricModel(\n  shapes: readonly IsometricShape[],\n  depthOption?: number,\n  guideMode: \"box\" | \"edges\" = \"box\",\n  elevation: number = ISOMETRIC_ELEVATION,\n): IsometricModel {\n  setElevation(elevation)\n  const rings: {\n    ring: IsometricPoint[]\n    hole: boolean\n    shape: number\n    tone?: string\n  }[] = []\n  shapes.forEach((shape, index) => {\n    const tone = shape.tone\n    rings.push({\n      ring: orient(cleanRing(shape.outer), true),\n      hole: false,\n      shape: index,\n      tone,\n    })\n    for (const hole of shape.holes ?? []) {\n      rings.push({\n        ring: orient(cleanRing(hole), false),\n        hole: true,\n        shape: index,\n        tone,\n      })\n    }\n  })\n\n  // 平面包围盒，用于自动高度与辅助线\n  let minX = Infinity\n  let minY = Infinity\n  let maxX = -Infinity\n  let maxY = -Infinity\n  for (const { ring } of rings) {\n    for (const [x, y] of ring) {\n      minX = Math.min(minX, x)\n      minY = Math.min(minY, y)\n      maxX = Math.max(maxX, x)\n      maxY = Math.max(maxY, y)\n    }\n  }\n  if (!rings.length) {\n    minX = minY = maxX = maxY = 0\n  }\n  const planSize = Math.max(maxX - minX, maxY - minY, 1)\n  const depth = Math.max(depthOption ?? planSize * AUTO_DEPTH_RATIO, 0)\n\n  // 背面剔除：法线 (ey, -ex) 与观察方向 (1, 1) 同侧才可见；\n  // 法线与观察方向垂直的墙在屏幕上是一条竖线，面积为零，也不画。\n  const segments: Segment[] = []\n  for (const { ring, tone } of rings) {\n    for (let i = 0; i < ring.length; i++) {\n      const [px, py] = ring[i]!\n      const [qx, qy] = ring[(i + 1) % ring.length]!\n      if (qy - py - (qx - px) > EPSILON) segments.push({ px, py, qx, qy, tone })\n    }\n  }\n  const walls = sortBackToFront(segments).map((s) => {\n    const [x0, y0] = project(s.px, s.py)\n    const [x1, y1] = project(s.qx, s.qy)\n    return { x0, y0, x1, y1, tone: s.tone }\n  })\n\n  // 投影后的包围盒：顶面在 z = depth（最高），底边在 z = 0（最低）\n  let bx0 = Infinity\n  let by0 = Infinity\n  let bx1 = -Infinity\n  let by1 = -Infinity\n  for (const { ring } of rings) {\n    for (const [x, y] of ring) {\n      const [px, py] = project(x, y)\n      bx0 = Math.min(bx0, px)\n      bx1 = Math.max(bx1, px)\n      by0 = Math.min(by0, py - depth * KZ)\n      by1 = Math.max(by1, py)\n    }\n  }\n  if (!rings.length) {\n    bx0 = by0 = bx1 = by1 = 0\n  }\n  const size = Math.max(bx1 - bx0, by1 - by0, 1)\n  const margin = size * 0.02\n\n  // 辅助线：\n  // - \"box\"：包围盒四条边沿两条等轴测轴延长（横平竖直的图形最合适）；\n  // - \"edges\"：沿图形自己的主方向——按边长统计各方向，取最长的两个，每个方向画两条外侧切线并延长。\n  //   斜向的图形（如一组斜杠）辅助线会贴着它的长边与齐平的端点。\n  const reach = planSize * 1.2\n  const line = (x0: number, y0: number, x1: number, y1: number) => {\n    const [ax, ay] = project(x0, y0)\n    const [bx, by] = project(x1, y1)\n    return `M${fmt(ax)} ${fmt(ay)}L${fmt(bx)} ${fmt(by)}`\n  }\n  const weights = new Map<number, { ux: number; uy: number; length: number }>()\n  for (const { ring } of rings) {\n    for (let i = 0; i < ring.length; i++) {\n      const [px, py] = ring[i]!\n      const [qx, qy] = ring[(i + 1) % ring.length]!\n      const length = Math.hypot(qx - px, qy - py)\n      if (length < EPSILON) continue\n      // 方向取 [0°, 180°)，按 0.5° 分桶，避免浮点误差把同一方向拆开\n      let angle = (Math.atan2(qy - py, qx - px) * 180) / Math.PI\n      angle = ((angle % 180) + 180) % 180\n      const key = Math.round(angle * 2) % 360\n      const entry = weights.get(key)\n      if (entry) entry.length += length\n      else {\n        const r = (key / 2) * (Math.PI / 180)\n        weights.set(key, { ux: Math.cos(r), uy: Math.sin(r), length })\n      }\n    }\n  }\n  const directions =\n    guideMode === \"edges\"\n      ? [...weights.values()].sort((a, b) => b.length - a.length).slice(0, 2)\n      : [\n          { ux: 1, uy: 0, length: 0 },\n          { ux: 0, uy: 1, length: 0 },\n        ]\n  const points = rings.flatMap(({ ring }) => ring)\n  // 地面网格：与辅助线同两个方向、同一个平面（z = 0），目标是图形的每条边都落在格线上。\n  // 每个方向单独算：所有顶点在法线方向上的距离若有足够大的公约数（如标志左端切口\n  // 0 / 78 / 156 / 416 的公约数 26），就按它等距铺；否则每条边各画一条线，外侧按平均间距延伸。\n  const floorTarget = planSize / 10\n  const floorReach = planSize * 0.9\n  const approxGcd = (a: number, b: number): number => {\n    const tolerance = planSize / 400\n    while (b > tolerance) [a, b] = [b, a % b]\n    return a\n  }\n  // 每个方向算出一组格线位置（法线方向上的绝对偏移），地面的各种材质都由这两组格线组合而成\n  const families = directions.map(({ ux, uy }) => {\n    const nx = -uy\n    const ny = ux\n    let lo = Infinity\n    let hi = -Infinity\n    let a0 = Infinity\n    let a1 = -Infinity\n    for (const [x, y] of points) {\n      lo = Math.min(lo, x * nx + y * ny)\n      hi = Math.max(hi, x * nx + y * ny)\n      a0 = Math.min(a0, x * ux + y * uy)\n      a1 = Math.max(a1, x * ux + y * uy)\n    }\n    const span = hi - lo\n    const offsets = [...new Set(points.map(([x, y]) => x * nx + y * ny - lo))]\n      .sort((p, q) => p - q)\n      .filter((d, i, all) => i === 0 || d - all[i - 1]! > planSize / 400)\n    let g = 0\n    for (const d of offsets) g = approxGcd(g, d)\n    let ds: number[]\n    let step: number\n    if (span > EPSILON && g >= span / 24) {\n      // 所有边的间距有公约数：按公约数等距铺，图形里的每条边都落在格线上；\n      // 公约数太大（格子太稀）时再等分，等分后边依然在格线上\n      step = g / Math.max(1, Math.round(g / floorTarget))\n      ds = []\n      for (let d = 0; d <= span + EPSILON; d += step) ds.push(d)\n    } else {\n      // 没有合适的公约数：每条边各画一条格线（像构造图那样），外侧按平均间距延伸\n      ds = offsets\n      step = offsets.length > 1 ? span / (offsets.length - 1) : floorTarget\n    }\n    const all = [...ds]\n    for (let d = -step; d >= -floorReach; d -= step) all.push(d)\n    for (let d = span + step; d <= span + floorReach; d += step) all.push(d)\n    all.sort((p, q) => p - q)\n    return {\n      ux,\n      uy,\n      nx,\n      ny,\n      offsets: all.map((d) => lo + d),\n      t0: a0 - floorReach,\n      t1: a1 + floorReach,\n    }\n  })\n  const lineOf = (f: (typeof families)[number], off: number) =>\n    line(\n      off * f.nx + f.t0 * f.ux,\n      off * f.ny + f.t0 * f.uy,\n      off * f.nx + f.t1 * f.ux,\n      off * f.ny + f.t1 * f.uy,\n    )\n  const floor = families\n    .flatMap((f) => f.offsets.map((off) => lineOf(f, off)))\n    .join(\"\")\n  const firstFamily = families[0]\n  const floorLines = firstFamily\n    ? firstFamily.offsets.map((off) => lineOf(firstFamily, off)).join(\"\")\n    : \"\"\n  // 两组格线的交点：解 n1·p = d1、n2·p = d2\n  const cross = (d1: number, d2: number): [number, number] | null => {\n    const [f1, f2] = families\n    if (!f1 || !f2) return null\n    const det = f1.nx * f2.ny - f1.ny * f2.nx\n    if (Math.abs(det) < EPSILON) return null\n    return [(d1 * f2.ny - f1.ny * d2) / det, (f1.nx * d2 - d1 * f2.nx) / det]\n  }\n  let floorDots = \"\"\n  let floorCells = \"\"\n  const [f1, f2] = families\n  if (families.length === 2 && f1 && f2) {\n    const r = planSize / 130\n    f1.offsets.forEach((d1) => {\n      f2.offsets.forEach((d2) => {\n        const p = cross(d1, d2)\n        if (!p) return\n        const [x, y] = project(p[0], p[1])\n        floorDots += `M${fmt(x - r)} ${fmt(y)}a${fmt(r)} ${fmt(r)} 0 1 0 ${fmt(r * 2)} 0a${fmt(r)} ${fmt(r)} 0 1 0 ${fmt(-r * 2)} 0`\n      })\n    })\n    for (let i = 0; i < f1.offsets.length - 1; i++) {\n      for (let j = 0; j < f2.offsets.length - 1; j++) {\n        if ((i + j) % 2) continue\n        const x0 = f1.offsets[i]!\n        const x1 = f1.offsets[i + 1]!\n        const y0 = f2.offsets[j]!\n        const y1 = f2.offsets[j + 1]!\n        const corners = [\n          cross(x0, y0),\n          cross(x1, y0),\n          cross(x1, y1),\n          cross(x0, y1),\n        ]\n        if (corners.some((c) => !c)) continue\n        floorCells +=\n          corners\n            .map((c, k) => {\n              const [x, y] = project(c![0], c![1])\n              return `${k ? \"L\" : \"M\"}${fmt(x)} ${fmt(y)}`\n            })\n            .join(\"\") + \"Z\"\n      }\n    }\n  }\n\n  const guides = directions\n    .flatMap(({ ux, uy }) => {\n      const nx = -uy\n      const ny = ux\n      let lo = Infinity\n      let hi = -Infinity\n      let a0 = Infinity\n      let a1 = -Infinity\n      for (const [x, y] of points) {\n        const d = x * nx + y * ny\n        const t = x * ux + y * uy\n        lo = Math.min(lo, d)\n        hi = Math.max(hi, d)\n        a0 = Math.min(a0, t)\n        a1 = Math.max(a1, t)\n      }\n      return [lo, hi].map((d) => {\n        const t0 = a0 - reach\n        const t1 = a1 + reach\n        return line(\n          d * nx + t0 * ux,\n          d * ny + t0 * uy,\n          d * nx + t1 * ux,\n          d * ny + t1 * uy,\n        )\n      })\n    })\n    .join(\"\")\n\n  const viewBox = {\n    x: bx0 - margin,\n    y: by0 - margin,\n    width: bx1 - bx0 + margin * 2,\n    height: by1 - by0 + margin * 2,\n  }\n  return {\n    viewBox,\n    unit: size / 500,\n    depth,\n    top: rings.map(({ ring }) => ringPath(ring, depth)).join(\"\"),\n    tops: shapes.map((shape, index) => ({\n      d: rings\n        .filter((r) => r.shape === index)\n        .map(({ ring }) => ringPath(ring, depth))\n        .join(\"\"),\n      tone: shape.tone,\n    })),\n    walls,\n    guides,\n    floor,\n    floorLines,\n    floorDots,\n    floorCells,\n    heightScale: KZ,\n  }\n}\n\n/** 高度为 h 时一面墙的路径：底边固定在 z = 0，顶边抬到 z = h。 */\nfunction wallPath(\n  { x0, y0, x1, y1 }: IsometricWall,\n  height: number,\n  heightScale = 1,\n) {\n  const h = height * heightScale\n  return `M${fmt(x0)} ${fmt(y0 - h)}L${fmt(x1)} ${fmt(y1 - h)}L${fmt(x1)} ${fmt(y1)}L${fmt(x0)} ${fmt(y0)}Z`\n}\n\n/**\n * 投影前把平面形状绕包围盒中心旋转 `degrees` 度（y 向下坐标系里顺时针为正）。\n * 建议取 90 的整数倍：这样图形的横竖边始终沿等轴测斜轴，仍是一块平放的板；\n * 其他角度（如 -45°）会让顶面看起来像一堵立着的墙。\n */\nfunction rotateShapes(\n  shapes: readonly IsometricShape[],\n  degrees: number,\n): IsometricShape[] {\n  if (!degrees) return shapes.map((s) => ({ ...s }))\n  const all = shapes.flatMap((s) => [...s.outer, ...(s.holes ?? []).flat()])\n  const xs = all.map(([x]) => x)\n  const ys = all.map(([, y]) => y)\n  const cx = (Math.min(...xs) + Math.max(...xs)) / 2\n  const cy = (Math.min(...ys) + Math.max(...ys)) / 2\n  const r = (degrees * Math.PI) / 180\n  const cos = Math.cos(r)\n  const sin = Math.sin(r)\n  const turn = (ring: readonly IsometricPoint[]): IsometricPoint[] =>\n    ring.map(([x, y]) => [\n      cx + (x - cx) * cos - (y - cy) * sin,\n      cy + (x - cx) * sin + (y - cy) * cos,\n    ])\n  return shapes.map((s) => ({\n    outer: turn(s.outer),\n    holes: s.holes?.map(turn),\n    tone: s.tone,\n  }))\n}\n\nexport { buildIsometricModel, ISOMETRIC_ELEVATION, rotateShapes, wallPath }\nexport type { IsometricModel, IsometricPoint, IsometricShape, IsometricWall }\n",
      "type": "registry:ui",
      "target": "@ui/isometric-mark/geometry.ts"
    },
    {
      "path": "src/registry/ui/isometric-mark/sound.ts",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\n/**\n * 按下 / 松开的音效：用 Web Audio 现场合成，不需要音频文件。\n * 按下是一声清脆的「咔」（短促的下滑正弦 + 高频带通噪声做触感，只留少量低频厚度），松开是更轻、更高的一声「嗒」。\n * 浏览器的自动播放限制见下面的 unlock：手机上第一次按下的声音会在松手时补放。\n */\nlet context: AudioContext | null = null\n/** 音频还没解锁时攒下的声音：解锁后补放，超过 PENDING_TTL 毫秒的就丢掉 */\nlet pending: { play: (ctx: AudioContext) => void; at: number }[] = []\nconst PENDING_TTL = 600\n\nfunction audio() {\n  if (typeof window === \"undefined\") return null\n  if (!context) {\n    const Ctor =\n      window.AudioContext ??\n      (window as unknown as { webkitAudioContext?: typeof AudioContext })\n        .webkitAudioContext\n    if (!Ctor) return null\n    context = new Ctor()\n  }\n  return context\n}\n\nfunction flush() {\n  const ctx = context\n  if (ctx?.state !== \"running\") return\n  removeUnlockListeners()\n  const now = performance.now()\n  const queue = pending\n  pending = []\n  for (const item of queue) if (now - item.at < PENDING_TTL) item.play(ctx)\n}\n\n/**\n * 手机浏览器只认「松手」类的手势解锁音频（pointerup、touchend、click、keydown），\n * 触摸的按下不算，所以手指按下那一刻 AudioContext 还是挂起的。\n * 在页面上监听这些手势：第一次松手就恢复 AudioContext 并补放刚才攒下的声音，之后按下也能直接出声。\n * 老版 iOS 还要在手势里真正播一段声音才算解锁，顺手播一个 1 帧的静音缓冲。\n */\nfunction unlock() {\n  const ctx = audio()\n  if (!ctx) return\n  if (ctx.state === \"running\") return flush()\n  const silent = ctx.createBufferSource()\n  silent.buffer = ctx.createBuffer(1, 1, ctx.sampleRate)\n  silent.connect(ctx.destination)\n  silent.start()\n  void ctx.resume().then(flush, () => {})\n}\n\nconst GESTURES = [\"pointerup\", \"touchend\", \"click\", \"keydown\"] as const\nlet listening = false\n\n/**\n * 解锁监听懒挂载：第一次真正要发声、而音频还挂起时才挂上，解锁后立即移除。\n * 只导入组件、不出声的页面不会创建 AudioContext，也不会在 window 上留下监听。\n */\nfunction listenForUnlock() {\n  if (listening) return\n  listening = true\n  for (const type of GESTURES)\n    window.addEventListener(type, unlock, { capture: true, passive: true })\n}\n\nfunction removeUnlockListeners() {\n  if (!listening) return\n  listening = false\n  for (const type of GESTURES) window.removeEventListener(type, unlock, true)\n}\n\n/** 有声就放；还没解锁就先记下，解锁后补放 */\nfunction withAudio(play: (ctx: AudioContext) => void) {\n  const ctx = audio()\n  if (!ctx) return\n  if (ctx.state === \"running\") {\n    removeUnlockListeners()\n    return play(ctx)\n  }\n  listenForUnlock()\n  // 只留最近两声：解锁前拖了一路滑块，补放时不能一下子炸出一串滴答\n  pending = [...pending, { play, at: performance.now() }].slice(-2)\n  void ctx.resume().then(flush, () => {})\n}\n\nfunction tone(\n  ctx: AudioContext,\n  from: number,\n  to: number,\n  duration: number,\n  volume: number,\n) {\n  const now = ctx.currentTime\n  const osc = ctx.createOscillator()\n  const gain = ctx.createGain()\n  osc.type = \"sine\"\n  osc.frequency.setValueAtTime(from, now)\n  osc.frequency.exponentialRampToValueAtTime(to, now + duration)\n  gain.gain.setValueAtTime(0.0001, now)\n  gain.gain.exponentialRampToValueAtTime(volume, now + 0.004)\n  gain.gain.exponentialRampToValueAtTime(0.0001, now + duration)\n  osc.connect(gain).connect(ctx.destination)\n  osc.start(now)\n  osc.stop(now + duration + 0.02)\n}\n\nfunction tick(\n  ctx: AudioContext,\n  frequency: number,\n  duration: number,\n  volume: number,\n) {\n  const now = ctx.currentTime\n  const length = Math.ceil(ctx.sampleRate * duration)\n  const buffer = ctx.createBuffer(1, length, ctx.sampleRate)\n  const data = buffer.getChannelData(0)\n  for (let i = 0; i < length; i++)\n    data[i] = (Math.random() * 2 - 1) * (1 - i / length) ** 3\n  const source = ctx.createBufferSource()\n  source.buffer = buffer\n  const filter = ctx.createBiquadFilter()\n  filter.type = \"bandpass\"\n  filter.frequency.value = frequency\n  filter.Q.value = 1.2\n  const gain = ctx.createGain()\n  gain.gain.value = volume\n  source.connect(filter).connect(gain).connect(ctx.destination)\n  source.start(now)\n}\n\nconst mix = (a: number, b: number, t: number) => a + (b - a) * t\n\n/**\n * 音色在「闷」与「脆」之间连续插值：0 是低沉的「咚」（长低频、暗噪声），\n * 1 是清脆的「咔」（短低频、加一层高频短音、亮噪声）。默认 0.6。\n */\nfunction playPress(crisp = 0.6) {\n  const t = Math.min(Math.max(crisp, 0), 1)\n  withAudio((ctx) => {\n    tone(\n      ctx,\n      mix(150, 340, t),\n      mix(62, 150, t),\n      mix(0.14, 0.06, t),\n      mix(0.22, 0.12, t),\n    )\n    if (t > 0.05)\n      tone(ctx, mix(900, 1600, t), mix(700, 1000, t), 0.03, 0.08 * t)\n    tick(ctx, mix(1600, 4200, t), mix(0.022, 0.013, t), mix(0.1, 0.22, t))\n  })\n}\n\nfunction playRelease(crisp = 0.6) {\n  const t = Math.min(Math.max(crisp, 0), 1)\n  withAudio((ctx) => {\n    tone(\n      ctx,\n      mix(260, 700, t),\n      mix(180, 520, t),\n      mix(0.06, 0.035, t),\n      mix(0.08, 0.06, t),\n    )\n    tick(ctx, mix(3000, 6000, t), mix(0.012, 0.009, t), mix(0.05, 0.13, t))\n  })\n}\n\n/* ---------- 静音偏好：存在 localStorage，全站带音效的组件共用 ---------- */\n\nconst STORAGE_KEY = \"yiui:sound:muted\"\n/** 旧版本用过的键：读到新键没写过时按它迁移，保证升级后偏好不丢。 */\nconst LEGACY_KEYS = [\"travinsy:sound:muted\", \"isometric-mark:muted\"]\nconst WATCHED_KEYS = [STORAGE_KEY, ...LEGACY_KEYS]\n/**\n * 同页广播：`storage` 事件只发给「其他」标签页，本页写 localStorage 不会触发它。\n * 带音效的组件各自有一份静音模块（互不 import），所以约定用同一个事件名互相通知。\n */\nconst MUTED_EVENT = \"yiui:sound-muted-change\"\nconst listeners = new Set<() => void>()\n\nfunction readMuted() {\n  try {\n    const current = window.localStorage.getItem(STORAGE_KEY)\n    if (current !== null) return current === \"1\"\n    for (const key of LEGACY_KEYS) {\n      const legacy = window.localStorage.getItem(key)\n      if (legacy === null) continue\n      window.localStorage.setItem(STORAGE_KEY, legacy)\n      for (const oldKey of LEGACY_KEYS) window.localStorage.removeItem(oldKey)\n      return legacy === \"1\"\n    }\n    return false\n  } catch {\n    return false\n  }\n}\n\nfunction setMuted(muted: boolean) {\n  try {\n    window.localStorage.setItem(STORAGE_KEY, muted ? \"1\" : \"0\")\n  } catch {\n    // 隐私模式等存不了时只在本页生效\n  }\n  for (const listener of listeners) listener()\n  // 通知同页其他带音效的组件\n  window.dispatchEvent(new Event(MUTED_EVENT))\n}\n\nfunction subscribe(listener: () => void) {\n  listeners.add(listener)\n  const notify = () => listener()\n  const onStorage = (event: StorageEvent) => {\n    if (event.key === null || WATCHED_KEYS.includes(event.key)) notify()\n  }\n  window.addEventListener(\"storage\", onStorage)\n  window.addEventListener(MUTED_EVENT, notify)\n  return () => {\n    listeners.delete(listener)\n    window.removeEventListener(\"storage\", onStorage)\n    window.removeEventListener(MUTED_EVENT, notify)\n  }\n}\n\n/** 读取 / 切换音效静音。服务端与水合阶段按未静音渲染，挂载后再读本地偏好。 */\nfunction useIsometricMarkMuted() {\n  const muted = React.useSyncExternalStore(subscribe, readMuted, () => false)\n  return [muted, setMuted] as const\n}\n\nexport { playPress, playRelease, useIsometricMarkMuted }\n",
      "type": "registry:ui",
      "target": "@ui/isometric-mark/sound.ts"
    }
  ],
  "type": "registry:ui"
}
