"use client"
import { cn } from "@/lib/utils"
import { Volume2, VolumeX } from "lucide-react"
import {
IsometricMark,
useIsometricMarkMuted,
type IsometricShape,
} from "@/components/ui/isometric-mark/isometric-mark"
// 本站标志「易」:上三道细斜杠是「日」,下三道粗斜杠是「勿」,右端齐平;坐标与站点 SVG 标志相同
const LOGO: readonly (readonly [number, number])[][] = [
// 上组:细斜杠
[
[156, 130],
[416, 0],
[416, 36],
[156, 166],
],
[
[156, 192],
[416, 62],
[416, 98],
[156, 228],
],
[
[156, 254],
[416, 124],
[416, 160],
[156, 290],
],
// 下组:粗斜杠
[
[0, 394],
[416, 186],
[416, 258],
[0, 466],
],
[
[78, 453],
[416, 284],
[416, 356],
[78, 525],
],
[
[156, 512],
[416, 382],
[416, 454],
[156, 584],
],
]
// 朝向 -65°、标准等轴测俯仰 35°、挤出 52:斜杠立起往右上斜,细的一组在后、粗的一组在前;
// 辅助线用 "edges",贴着斜杠的长边与齐平的切口;斜杠本身向右上斜,顶面用横纹,与斜杠长边错开不产生视差
// 上三道细斜杠用主题色由浅到深(从后往前),下三道粗斜杠保持单色线稿,主次分明
const TONES = [
"color-mix(in oklab, var(--primary) 55%, var(--background))",
"color-mix(in oklab, var(--primary) 78%, var(--background))",
"var(--primary)",
]
const MARK_SHAPES: readonly IsometricShape[] = LOGO.map((bar, index) => ({
outer: [...bar],
tone: TONES[index],
}))
// 标注标签:圆点取自 --flow-* 色调,说明这块几何由哪个部分生成
function Callout({
tone,
className,
children,
}: {
tone: "blue" | "violet" | "amber" | "green"
className?: string
children: React.ReactNode
}) {
return (
<span
className={cn(
"absolute hidden items-center gap-1.5 rounded-md border border-edge bg-background/90 px-2 py-1 font-mono text-[10px] text-muted-foreground shadow-xs backdrop-blur-sm sm:flex",
className,
)}
>
<span
aria-hidden="true"
className="size-1.5 rounded-full"
style={{ backgroundColor: `var(--flow-${tone})` }}
/>
{children}
</span>
)
}
/** 音效开关:静音偏好存在本地,全站的等轴测标志共用 */
function SoundToggle() {
const [muted, setMuted] = useIsometricMarkMuted()
return (
<button
type="button"
aria-pressed={!muted}
aria-label={muted ? "打开点击音效" : "关闭点击音效"}
title={muted ? "打开点击音效" : "关闭点击音效"}
onClick={() => setMuted(!muted)}
className="absolute top-3 right-3 z-10 inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground [&_svg]:size-3.5"
>
{muted ? <VolumeX /> : <Volume2 />}
</button>
)
}
// 地面网格(floor)与辅助线同一平面、对齐标志的每条边;主题色光晕与接触阴影让标志落在地面上,标注标签讲清几何怎么来
export default function IsometricMarkDemo() {
return (
<div className="relative isolate flex min-h-72 w-full max-w-2xl items-center justify-center overflow-hidden rounded-lg border border-edge bg-card px-4 pt-6 pb-12 sm:min-h-96">
<SoundToggle />
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(ellipse_55%_42%_at_50%_58%,color-mix(in_oklab,var(--primary)_14%,transparent),transparent_75%)]"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute top-[62%] left-1/2 -z-10 h-10 w-[34%] -translate-x-1/2 -rotate-[22deg] rounded-[50%] bg-foreground/10 blur-2xl dark:hidden"
/>
<div className="relative w-full max-w-[17rem] sm:max-w-80">
<IsometricMark
shapes={MARK_SHAPES}
rotation={-65}
elevation={35}
depth={52}
material="lines"
label="易"
guides="edges"
floor
sound
/>
</div>
<Callout tone="blue" className="top-[18%] left-5">
TOP · 横纹顶面
</Callout>
<Callout tone="violet" className="bottom-[26%] left-5">
WALL · 侧墙自动消隐
</Callout>
<Callout tone="amber" className="top-[24%] right-5">
depth = 52
</Callout>
<Callout tone="green" className="right-5 bottom-[26%]">
SVG · 可用 className 缩放
</Callout>
<p className="absolute inset-x-0 bottom-3 flex items-center justify-center gap-2 font-mono text-[10px] tracking-widest text-muted-foreground">
<span aria-hidden="true" className="size-1 bg-primary" />
移动指针看高光 · 按住压下
</p>
</div>
)
}
安装
pnpm dlx shadcn@latest add https://yiui.dev/r/isometric-mark.json用法
传入平面形状即可,坐标就是平面 logo 自己的坐标(y 向下)。形状数组请定义在组件外面,避免每次渲染重新计算几何。
import {
IsometricMark,
type IsometricShape,
} from "@/components/ui/isometric-mark/isometric-mark"
const SHAPES: readonly IsometricShape[] = [
{
outer: [
[0, 0],
[100, 0],
[100, 100],
[0, 100],
],
holes: [
[
[35, 35],
[65, 35],
[65, 65],
[35, 65],
],
],
},
]
export function BrandMark() {
return <IsometricMark shapes={SHAPES} label="我的标志" className="w-64" />
}宽度由 className 控制,高度按 viewBox 自动适配。线宽、斜纹间距、虚线都跟着图形尺寸等比缩放,所以放大缩小时观感一致。
朝向与视角
rotation 在投影前把图形绕中心旋转,决定它平躺在地上的朝向;elevation 是视线的俯仰角,默认约 35.26°(标准等轴测),调大更接近俯视,调小更接近平视。
取 0、90、180、-90 时图形的横竖边沿等轴测的两条斜轴,像一块平放的板;斜向的图形可以取任意角度,让它的长边顺着某条斜轴更好看。页首的本站标志用的是 rotation={-65}:
<IsometricMark shapes={SHAPES} rotation={-65} label="易" guides="edges" />想直观比较不同朝向和俯仰,打开下方任一示例的「调整」面板拖动即可。
示例
每个示例的标题栏右侧都有「调整」开关:打开后可以实时调整朝向、俯仰、挤出高度、顶面材质、地面、辅助线与音色,并开关按下、高光与音效;未改动的项显示示例自己的取值。调整时画框固定,容器大小不变。
多个形状与色调
shapes 可以放多块互不相连的多边形,它们之间的遮挡自动计算;每块可以用 tone 单独着色。下面是 shadcn/ui 的标志:两道斜杠各是一块,分别取主题色的深浅,地面用条纹 floor="lines"。
import {
IsometricMark,
type IsometricShape,
} from "@/components/ui/isometric-mark/isometric-mark"
// shadcn/ui 的标志:两道互不相连的斜杠,各自着主题色的深浅,遮挡关系自动计算(圆头简化为方头)
const SHAPES: readonly IsometricShape[] = [
// 长的一道
{
outer: [
[403, 6],
[432, 36],
[36, 432],
[6, 403],
],
tone: "var(--primary)",
},
// 短的一道
{
outer: [
[444, 236],
[474, 265],
[265, 474],
[236, 444],
],
tone: "color-mix(in oklab, var(--primary) 55%, var(--background))",
},
]
export default function IsometricMarkShadcnDemo() {
return (
<div className="relative isolate flex min-h-80 w-full items-center justify-center overflow-hidden px-6 py-8">
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(ellipse_55%_45%_at_50%_58%,color-mix(in_oklab,var(--primary)_12%,transparent),transparent_75%)]"
/>
<div className="w-full max-w-64">
<IsometricMark
shapes={SHAPES}
rotation={45}
depth={56}
guides="edges"
floor="lines"
material="lines"
sound
label="shadcn/ui"
/>
</div>
</div>
)
}
带孔的形状
holes 在外轮廓里挖孔,孔壁同样按朝向自动消隐。下面是 Pi 的像素标志:左边一块挖出方孔,右下的方块独立成一块并着主题色,顶面用网纹材质,地面用棋盘格 floor="checker"。
import {
IsometricMark,
type IsometricShape,
} from "@/components/ui/isometric-mark/isometric-mark"
// Pi 的像素标志:左边一块挖出方孔,右下一块独立的方块着主题色;孔壁同样按朝向自动消隐
const SHAPES: readonly IsometricShape[] = [
{
outer: [
[0, 0],
[360, 0],
[360, 240],
[240, 240],
[240, 360],
[120, 360],
[120, 480],
[0, 480],
],
holes: [
[
[120, 120],
[240, 120],
[240, 240],
[120, 240],
],
],
},
{
outer: [
[360, 240],
[480, 240],
[480, 480],
[360, 480],
],
tone: "var(--primary)",
},
]
export default function IsometricMarkPiDemo() {
return (
<div className="relative isolate flex min-h-80 w-full items-center justify-center overflow-hidden px-6 py-8">
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(ellipse_55%_45%_at_50%_58%,color-mix(in_oklab,var(--primary)_12%,transparent),transparent_75%)]"
/>
<div className="w-full max-w-64">
<IsometricMark
shapes={SHAPES}
rotation={-90}
depth={60}
guides
floor="checker"
material="cross"
sound
label="Pi"
/>
</div>
</div>
)
}
静态图形
任何多边形都可以,最简单的一个三角形也行。下面是 Vercel 的三角标:素面材质(material="plain")、包围盒辅助线(guides),关掉了 highlight;需要完全静态的插图时再关掉 interactive。地面用站点的点阵底纹 floor="dots"。
import {
IsometricMark,
type IsometricShape,
} from "@/components/ui/isometric-mark/isometric-mark"
// Vercel 的三角标:最简单的单个多边形,素面 + 包围盒辅助线;关掉指针高光,画面更安静
const SHAPES: readonly IsometricShape[] = [
{
outer: [
[240, 32],
[480, 448],
[0, 448],
],
},
]
export default function IsometricMarkVercelDemo() {
return (
<div className="relative isolate flex min-h-80 w-full items-center justify-center overflow-hidden px-6 py-8">
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(ellipse_55%_45%_at_50%_58%,color-mix(in_oklab,var(--primary)_12%,transparent),transparent_75%)]"
/>
<div className="w-full max-w-64">
<IsometricMark
shapes={SHAPES}
rotation={0}
depth={110}
guides
floor="dots"
material="plain"
highlight={false}
sound
label="Vercel"
/>
</div>
</div>
)
}
材质、地面与辅助线
material是顶面材质:hatch斜纹「/」(默认)、hatch-reverse斜纹「\」、cross网纹、dots点阵、lines横纹、plain素面。斜纹与图形长边接近平行时会有视差感,换一个方向或用横纹即可。guides是背景虚线:true/"box"画包围盒四边的延长线,适合横平竖直的图形;"edges"沿图形自己最长的两个边方向画外侧切线,适合斜向的图形。floor是地面,和辅助线同方向、同一平面,并对齐图形的每条边:true/"grid"方格网、"lines"条纹、"checker"棋盘格,"dots"则是站点的点阵底纹(8px 间距、1px 方点,铺在屏幕平面上)。
挤出高度与按下
depth 是挤出高度,单位与形状坐标相同,默认取图形平面尺寸的 12%。按住指针时图形被压到 pressedDepth(默认是 depth 的一半,设为 0 会被完全压平),松开或移出后弹回;线条高光跟随鼠标在窗口中的位置。触屏与“减少动态效果”下高光自动关闭,按下状态瞬间切换。这些都可以在示例的调整面板里直接试。
点击音效
sound 打开按下与松开时的合成音效(Web Audio 现场生成,不需要音频文件),默认关闭,本页所有示例都已打开。浏览器要求先有用户操作才能出声,所以音频在第一次按下时才初始化。静音偏好存在浏览器本地,页面上所有标志共用:useIsometricMarkMuted() 返回 [muted, setMuted],可以用它做一个静音按钮,页首的示例右上角就是这样。这份偏好与站内其他带音效的组件共用(localStorage 的 travinsy:sound:muted):同一页面里一处静音,其他组件的静音按钮与音效立刻跟上;换到另一个标签页也会同步。soundTone 调音色:0 偏闷,是低沉的「咚」;1 偏脆,是清脆的「咔」,默认 0.6。
const [muted, setMuted] = useIsometricMarkMuted()
<IsometricMark shapes={SHAPES} sound soundTone={0.6} />
<button onClick={() => setMuted(!muted)}>{muted ? "打开音效" : "静音"}</button>几何说明
组件不依赖手工描的路径,所有面都由平面多边形自动算出:
- 投影:
X = (x - y)·cos30°,Y = (x + y)·k₁ - z·k₂,其中k₁ = sin(e)·√2·cos30°、k₂ = cos(e)·√2·cos30°,e是elevation;默认 35.26° 时k₁ = 0.5、k₂ = 1,即标准等轴测。观察者位于(+x, +y, +z)一侧,viewBox 按投影后的包围盒自动计算并留少量边距。 - 面的组成:顶面在
z = depth,外轮廓与孔一起用evenodd填充;每个环的每条边挤出一面侧墙,孔被挤成通孔。 - 背面剔除:外轮廓取正面积、孔取负面积,所有环的“实体外侧”法线统一为
(ey, -ex),只保留朝向观察者的墙。与观察方向垂直的墙在屏幕上面积为零,不绘制。 - 消隐:画家算法。墙在屏幕上只占据
x - y方向上的一段范围,范围重叠的两面墙比较重叠区间中点的x + y,得到“谁挡住谁”,再做拓扑排序,凹多边形也能排对。顺序只取决于平面形状,按下动画时不用重排。顶面最后绘制。 - 按下动画:一个弹簧驱动的高度值同时重算所有侧墙路径(只是 O(边数) 的字符串重建),顶面路径不变,整体用
y偏移下移,材质纹理因此始终贴着轮廓。 - 地面:辅助线与地面网格取同样的两个方向。每个方向上,若所有边到外侧切线的距离有足够大的公约数,就按它等距铺线;否则每条边各画一条线,外侧按平均间距延伸。
API
IsometricMark
视线的俯仰角(度,5–85)。调大更接近俯视(地面更方、厚度更薄),调小更接近平视(地面更扁、厚度更高)
类型number | undefined35.26(标准等轴测)平面形状,坐标是平面 logo 自己的坐标(y 向下)。 请在组件外定义为常量,每次渲染传入新数组会重新计算几何。
类型readonly IsometricShape[]投影前绕图形中心旋转的角度(度),用来选择图形平躺在地上的朝向。 取 90 的整数倍时横竖边沿等轴测斜轴,像一块平放的板;斜向的图形可取任意角度,让长边顺着斜轴
类型number | undefined0挤出高度,单位与形状坐标相同;默认取图形平面尺寸的 12%
类型number | undefined按下后的挤出高度,默认是 depth 的一半;0 为完全压平
number | undefined点击 / 触摸时是否有按下效果
类型boolean | undefinedtrue线条是否有跟随鼠标的高光;触屏与“减少动态效果”下自动关闭
类型boolean | undefinedtrue背景里的虚线辅助线。true 或 "box":包围盒四边沿等轴测轴延长;
"edges":沿图形自己最长的两个边方向画外侧切线(适合斜向的图形,辅助线贴着长边与齐平的端点)
"box" | "edges" | boolean | undefinedfalse顶面材质:hatch 斜纹「/」、hatch-reverse 斜纹「\」、cross 交叉网纹、dots 点阵、lines 横纹、plain 素面。
斜纹与图形的长边接近平行时会产生视差感,换成另一个方向或横纹即可
IsometricMaterial | undefined"hatch"图形下方的地面,与辅助线同方向、同平面、对齐每条边,向四周渐隐。
true / "grid" 方格网、"lines" 只有一个方向的条纹、"dots" 站点的点阵底纹(屏幕平面,8px 间距)、"checker" 棋盘格
boolean | IsometricFloor | undefinedfalse按下与松开时播放合成音效(Web Audio,无需音频文件);尊重全站静音偏好,见 useIsometricMarkMuted
类型boolean | undefinedfalse音色:0 偏闷(低沉的「咚」),1 偏脆(清脆的「咔」)
类型number | undefined0.6可访问名称;不传时整个图形对读屏隐藏
类型string | undefined其余属性透传到根 svg,className 与内置样式合并。
useIsometricMarkMuted
function useIsometricMarkMuted(): readonly [
muted: boolean,
setMuted: (muted: boolean) => void,
]读写点击音效的静音偏好。偏好存在 localStorage 的 travinsy:sound:muted,与站内其他带音效的组件共用,同一页面的所有标志共享(写入后广播 travinsy:sound-muted-change 事件)并在标签页之间同步;服务端与水合阶段按未静音渲染。
类型
type IsometricPoint = readonly [number, number]
type IsometricShape = {
/** 外轮廓,顺时针或逆时针都可以 */
outer: readonly IsometricPoint[]
/** 孔,会被挤成通孔 */
holes?: readonly (readonly IsometricPoint[])[]
/** 这块形状的色调(任意 CSS 颜色,如 var(--primary)):顶面与侧墙淡淡着色、线条取同色 */
tone?: string
}
type IsometricMaterial =
"hatch" | "hatch-reverse" | "cross" | "dots" | "lines" | "plain"
type IsometricFloor = "grid" | "lines" | "dots" | "checker"页首的本站标志就用了 tone:上三道细斜杠用主题色由浅到深,下三道粗斜杠保持单色线稿。
data-slot
| 元素 | data-slot |
|---|---|
| 根元素 | isometric-mark |
| 单面侧墙 | isometric-mark-wall |
| 顶面 | isometric-mark-top |
| 背景辅助线 | isometric-mark-guides |
| 地面 | isometric-mark-floor(data-floor 为具体材质) |
可访问性
- 不传
label时根元素是aria-hidden,读屏完全忽略,适合作为纯装饰;传入label后变成role="img"并带上aria-label。 - 按下效果只响应指针,没有键盘等价操作,不承载任何功能。需要可点击时请把它包进
a或button,由外层提供语义与键盘支持。 - 高光在触屏设备(
(hover: none))与prefers-reduced-motion下自动关闭;减少动态效果时按下状态瞬间切换,不播放弹簧。 - 音效默认关闭;打开后请同时提供静音入口(
useIsometricMarkMuted),音效只是点缀,不承载信息。 - 颜色由
--foreground、--background与形状的tone混合得到;暗色模式下顶面、线条、纹理与着色比例单独加重,亮暗模式与颜色主题下都有足够的对比。
注意事项
- 高光位置取鼠标在窗口中的相对位置并映射到图形范围,因此鼠标不在图形上时高光也会随之移动;图形不在视口内(含 80px 缓冲)时不监听鼠标。
- 顶面与侧墙是实心填充(
--top、--wall,亮色下等于背景色),用来遮挡后面的线,所以图形背后若有图案会被盖住;地面与背景辅助线画在最底层。 depth默认取图形平面尺寸的 12%;pressedDepth默认是depth的一半,设为0则会被完全压平。- 颜色变量
--stroke、--pattern、--guide、--top、--wall以及着色比例--tone-top、--tone-wall、--tone-line定义在根元素的 class 里(亮暗各一套),可以用className覆盖,例如[--stroke:var(--primary)]。 floor="dots"按图形的实际渲染尺寸换算 8px,尺寸变化时自动重算。- 几何计算放在同目录的
geometry.ts,是不依赖 React 的纯函数;音效在sound.ts。
源码
"use client"
import * as React from "react"
import {
motion,
useInView,
useMotionValue,
useReducedMotion,
useSpring,
useTransform,
type MotionValue,
} from "motion/react"
import { cn } from "@/lib/utils"
import {
buildIsometricModel,
ISOMETRIC_ELEVATION,
rotateShapes,
wallPath,
type IsometricModel,
type IsometricPoint,
type IsometricShape,
type IsometricWall,
} from "./geometry"
import { playPress, playRelease, useIsometricMarkMuted } from "./sound"
/** 按下 / 弹起共用的弹簧,手感接近真实按键。 */
const PRESS_SPRING = { stiffness: 200, damping: 18, mass: 0.5 }
/** 高光跟随鼠标的弹簧,比按下更“跟手”。 */
const LIGHT_SPRING = { stiffness: 300, damping: 30, mass: 0.1 }
type IsometricMarkProps = Omit<React.ComponentProps<"svg">, "children"> & {
/**
* 平面形状,坐标是平面 logo 自己的坐标(y 向下)。
* 请在组件外定义为常量,每次渲染传入新数组会重新计算几何。
*/
shapes: readonly IsometricShape[]
/**
* 投影前绕图形中心旋转的角度(度),用来选择图形平躺在地上的朝向。
* 取 90 的整数倍时横竖边沿等轴测斜轴,像一块平放的板;斜向的图形可取任意角度,让长边顺着斜轴
* @default 0
*/
rotation?: number
/**
* 视线的俯仰角(度,5–85)。调大更接近俯视(地面更方、厚度更薄),调小更接近平视(地面更扁、厚度更高)
* @default 35.26(标准等轴测)
*/
elevation?: number
/** 挤出高度,单位与形状坐标相同;默认取图形平面尺寸的 12% */
depth?: number
/** 按下后的挤出高度,默认是 `depth` 的一半;`0` 为完全压平 */
pressedDepth?: number
/**
* 点击 / 触摸时是否有按下效果
* @default true
*/
interactive?: boolean
/**
* 线条是否有跟随鼠标的高光;触屏与“减少动态效果”下自动关闭
* @default true
*/
highlight?: boolean
/**
* 背景里的虚线辅助线。`true` 或 `"box"`:包围盒四边沿等轴测轴延长;
* `"edges"`:沿图形自己最长的两个边方向画外侧切线(适合斜向的图形,辅助线贴着长边与齐平的端点)
* @default false
*/
guides?: boolean | "box" | "edges"
/**
* 顶面材质:`hatch` 斜纹「/」、`hatch-reverse` 斜纹「\」、`cross` 交叉网纹、`dots` 点阵、`lines` 横纹、`plain` 素面。
* 斜纹与图形的长边接近平行时会产生视差感,换成另一个方向或横纹即可
* @default "hatch"
*/
material?: IsometricMaterial
/**
* 图形下方的地面,与辅助线同方向、同平面、对齐每条边,向四周渐隐。
* `true` / `"grid"` 方格网、`"lines"` 只有一个方向的条纹、`"dots"` 站点的点阵底纹(屏幕平面,8px 间距)、`"checker"` 棋盘格
* @default false
*/
floor?: boolean | IsometricFloor
/**
* 按下与松开时播放合成音效(Web Audio,无需音频文件);尊重全站静音偏好,见 useIsometricMarkMuted
* @default false
*/
sound?: boolean
/**
* 音色:0 偏闷(低沉的「咚」),1 偏脆(清脆的「咔」)
* @default 0.6
*/
soundTone?: number
/** 可访问名称;不传时整个图形对读屏隐藏 */
label?: string
}
/** 单面侧墙:高度由弹簧驱动,墙路径随高度重算,顶边始终贴着顶面。 */
function IsometricMarkWall({
wall,
height,
gradientId,
heightScale,
}: {
wall: IsometricWall
height: MotionValue<number>
gradientId: string
heightScale: number
}) {
const d = useTransform(height, (h) => wallPath(wall, h, heightScale))
// 同一面先画底色线再画高光线,后画的近处面会盖住远处面的线;有色调时墙面极淡着色、线条取同色
return (
<g
data-slot="isometric-mark-wall"
style={
wall.tone
? ({
"--wall-fill": `color-mix(in oklab, ${wall.tone} var(--tone-wall), var(--wall))`,
"--wall-stroke": `color-mix(in oklab, ${wall.tone} var(--tone-line), var(--background))`,
} as React.CSSProperties)
: undefined
}
>
<motion.path
d={d}
fill="var(--wall-fill, var(--wall))"
stroke="var(--wall-stroke, var(--stroke))"
/>
<motion.path d={d} fill="none" stroke={`url(#${gradientId})`} />
</g>
)
}
type IsometricMarkTuning = Partial<
Pick<
IsometricMarkProps,
| "rotation"
| "depth"
| "elevation"
| "guides"
| "material"
| "interactive"
| "highlight"
| "sound"
| "soundTone"
| "floor"
>
> & {
/** 固定画框:沿用原属性下的 viewBox,调整时容器大小与图形比例都不变 */
stableFrame?: boolean
/** 回报示例自己的实际取值(未被覆盖前,含自动高度与默认俯仰),面板据此显示当前值 */
onReport?: (values: IsometricMarkReport) => void
}
type IsometricMarkReport = {
rotation: number
elevation: number
depth: number
guides: "box" | "edges" | false
material: IsometricMaterial
interactive: boolean
highlight: boolean
sound: boolean
soundTone: number
floor: IsometricFloor | false
}
/**
* 可选的调整上下文:子树里所有 IsometricMark 的对应属性被这里的值覆盖。
* 用于调试面板一类的场景;不包这一层时组件行为不变。
*/
const IsometricMarkTuningContext = React.createContext<IsometricMarkTuning>({})
type IsometricFloor = "grid" | "lines" | "dots" | "checker"
type IsometricMaterial =
"hatch" | "hatch-reverse" | "cross" | "dots" | "lines" | "plain"
/** 各材质在一个 size×size 平铺单元里的路径(u 为一个像素对应的长度) */
function materialPath(material: IsometricMaterial, size: number, u: number) {
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}`
const back = `M${-u} ${size - u}l${u * 2} ${u * 2}M0 0l${size} ${size}M${size - u} ${-u}l${u * 2} ${u * 2}`
switch (material) {
case "hatch":
return slash
case "hatch-reverse":
return back
case "cross":
return slash + back
case "lines":
return `M0 ${size / 2}H${size}`
default:
return ""
}
}
/** 把平面多边形挤出成等轴测线条 3D,带斜纹顶面、跟随鼠标的高光与按下效果。 */
function IsometricMark(allProps: IsometricMarkProps) {
const tuning = React.useContext(IsometricMarkTuningContext)
const { stableFrame, onReport, ...overrides } = tuning
// 固定画框:沿用按原属性算出的 viewBox,调整时画框与图形比例都不变,
// 容器既不长高也不把图形挤小;转到画框外的部分照常显示(svg overflow: visible)
const {
shapes,
rotation = 0,
depth,
guides = false,
elevation,
material = "hatch",
interactive = true,
highlight = true,
sound = false,
soundTone = 0.6,
floor = false,
} = allProps
const baseModel = React.useMemo(
() =>
stableFrame || onReport
? buildIsometricModel(
rotateShapes(shapes, rotation),
depth,
guides === "edges" ? "edges" : "box",
elevation,
)
: undefined,
[stableFrame, onReport, shapes, rotation, depth, guides, elevation],
)
const fixedViewBox = stableFrame ? baseModel?.viewBox : undefined
React.useEffect(() => {
if (!onReport || !baseModel) return
onReport({
rotation,
elevation: elevation ?? ISOMETRIC_ELEVATION,
depth: baseModel.depth,
guides: guides === true ? "box" : guides,
material,
interactive,
highlight,
sound,
soundTone,
floor: floor === true ? "grid" : floor,
})
}, [
onReport,
baseModel,
rotation,
elevation,
guides,
material,
interactive,
highlight,
sound,
soundTone,
floor,
])
return (
<IsometricMarkView
{...allProps}
{...overrides}
fixedViewBox={fixedViewBox}
/>
)
}
function IsometricMarkView({
shapes,
rotation = 0,
depth: depthProp,
pressedDepth,
interactive = true,
highlight = true,
guides = false,
material = "hatch",
elevation,
floor = false,
sound = false,
soundTone = 0.6,
label,
className,
ref,
onPointerDown,
onPointerUp,
onPointerCancel,
onPointerLeave,
fixedViewBox,
...props
}: IsometricMarkProps & { fixedViewBox?: IsometricModel["viewBox"] }) {
const id = React.useId()
const patternId = `isometric-mark-pattern-${id}`
const gradientId = `isometric-mark-light-${id}`
const model = React.useMemo(
() =>
buildIsometricModel(
rotateShapes(shapes, rotation),
depthProp,
guides === "edges" ? "edges" : "box",
elevation,
),
[shapes, rotation, depthProp, guides, elevation],
)
const { unit, depth } = model
const viewBox = fixedViewBox ?? model.viewBox
const pressedHeight = pressedDepth ?? depth / 2
const rootRef = React.useRef<SVGSVGElement | null>(null)
const setRef = React.useCallback(
(node: SVGSVGElement | null) => {
rootRef.current = node
if (typeof ref === "function") ref(node)
else if (ref) ref.current = node
},
[ref],
)
const reduceMotion = useReducedMotion()
const inView = useInView(rootRef, { margin: "80px" })
// 当前挤出高度;顶面整体下移 depth - height,侧墙按 height 重算
const [pressed, setPressed] = React.useState(false)
const height = useSpring(depth, PRESS_SPRING)
React.useEffect(() => {
const target = pressed ? pressedHeight : depth
if (reduceMotion) height.jump(target)
else height.set(target)
}, [pressed, pressedHeight, depth, reduceMotion, height])
// 用 y 偏移而不是 transform 字符串,斜纹才会跟着轮廓一起走
const topOffset = useTransform(height, (h) => (depth - h) * model.heightScale)
// 高光:鼠标在窗口中的相对位置映射到 viewBox
const mouseX = useMotionValue(0.5)
const mouseY = useMotionValue(0.5)
const lightX = useSpring(
useTransform(mouseX, [0, 1], [viewBox.x, viewBox.x + viewBox.width]),
LIGHT_SPRING,
)
const lightY = useSpring(
useTransform(mouseY, [0, 1], [viewBox.y, viewBox.y + viewBox.height]),
LIGHT_SPRING,
)
React.useEffect(() => {
if (!highlight || reduceMotion || !inView) return
if (window.matchMedia("(hover: none)").matches) return
const handleMouseMove = (event: MouseEvent) => {
mouseX.set(event.clientX / window.innerWidth)
mouseY.set(event.clientY / window.innerHeight)
}
window.addEventListener("mousemove", handleMouseMove, { passive: true })
return () => window.removeEventListener("mousemove", handleMouseMove)
}, [highlight, reduceMotion, inView, mouseX, mouseY])
const [muted] = useIsometricMarkMuted()
const audible = sound && !muted
const pressedRef = React.useRef(false)
// 点阵地面要和站点的 bg-dots 一样按屏幕像素算(8px 间距、1px 方点),所以量出 1px 对应多少 viewBox 单位
const [pixel, setPixel] = React.useState(unit * 2)
React.useEffect(() => {
const svg = rootRef.current
if (floor !== "dots" || !svg) return
const update = () => {
const width = svg.getBoundingClientRect().width
if (width > 0) setPixel(viewBox.width / width)
}
update()
const observer = new ResizeObserver(update)
observer.observe(svg)
return () => observer.disconnect()
}, [floor, viewBox.width])
// 各种地面的浓淡:格点面积小要加重,棋盘格是整块填色要减轻
const floorStrength =
floor === "dots"
? 3.2
: floor === "checker"
? 0.45
: floor === "lines"
? 1.3
: 1
const release = () => {
if (pressedRef.current && audible) playRelease(soundTone)
pressedRef.current = false
setPressed(false)
}
const hatch = unit * 10
return (
<svg
ref={setRef}
data-slot="isometric-mark"
xmlns="http://www.w3.org/2000/svg"
viewBox={`${viewBox.x} ${viewBox.y} ${viewBox.width} ${viewBox.height}`}
fill="none"
role={label ? "img" : undefined}
aria-label={label}
aria-hidden={label ? undefined : true}
className={cn(
"block h-auto w-full touch-manipulation overflow-visible select-none",
// 颜色变量:顶面、侧墙、纹理、线条、着色比例。暗色下背景接近黑色,按同样比例混色几乎看不见,
// 所以单独提亮:顶面比背景亮一档、侧墙再暗一点形成体积,纹理与线条加重,着色比例加大
"[--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)]",
"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))]",
className,
)}
onPointerDown={(event) => {
onPointerDown?.(event)
if (!interactive) return
// 按住压下时别把旁边的文字一起选中(拖动或双击会从这里开始选区)
if (event.pointerType === "mouse") event.preventDefault()
if (audible) playPress(soundTone)
pressedRef.current = true
setPressed(true)
}}
onPointerUp={(event) => {
onPointerUp?.(event)
release()
}}
onPointerCancel={(event) => {
onPointerCancel?.(event)
release()
}}
onPointerLeave={(event) => {
onPointerLeave?.(event)
release()
}}
{...props}
>
<defs>
<pattern
id={patternId}
width={hatch}
height={hatch}
patternUnits="userSpaceOnUse"
>
{material === "dots" ? (
<circle
cx={hatch / 2}
cy={hatch / 2}
r={unit * 1.1}
fill="var(--pattern)"
/>
) : (
<path
d={materialPath(material, hatch, unit)}
stroke="var(--pattern)"
strokeWidth={unit}
/>
)}
</pattern>
<motion.radialGradient
id={gradientId}
cx={lightX}
cy={lightY}
r={unit * 200}
gradientUnits="userSpaceOnUse"
>
<stop stopColor="var(--foreground)" stopOpacity="0.7" />
<stop offset="1" stopColor="var(--foreground)" stopOpacity="0" />
</motion.radialGradient>
</defs>
{floor ? (
<>
{/* 地面网格直接用径向渐变描边:中心清晰、向外淡出,不影响图形本身 */}
<radialGradient
id={`${patternId}-floor`}
cx={viewBox.x + viewBox.width / 2}
cy={viewBox.y + viewBox.height / 2}
r={Math.max(viewBox.width, viewBox.height) * 0.7}
gradientUnits="userSpaceOnUse"
>
<stop
offset="0"
style={{
stopColor: "var(--foreground)",
stopOpacity: 0.1 * floorStrength,
}}
/>
<stop
offset="0.55"
style={{
stopColor: "var(--foreground)",
stopOpacity: 0.07 * floorStrength,
}}
/>
<stop
offset="1"
style={{ stopColor: "var(--foreground)", stopOpacity: 0 }}
/>
</radialGradient>
{floor === "dots" ? (
// 格点地面用站点的点阵底纹:8px 间距、1px 方点、前景色 34%(同 grid.css 的 bg-dots),
// 铺在屏幕平面上,用径向渐变遮罩向四周淡出
<>
<pattern
id={`${patternId}-dots`}
width={pixel * 8}
height={pixel * 8}
patternUnits="userSpaceOnUse"
>
<rect
x={pixel * 4}
y={pixel * 4}
width={pixel}
height={pixel}
fill="color-mix(in oklab, var(--foreground) 34%, transparent)"
/>
</pattern>
<mask
id={`${patternId}-dots-mask`}
maskUnits="userSpaceOnUse"
x={viewBox.x - viewBox.width * 2}
y={viewBox.y - viewBox.height * 2}
width={viewBox.width * 5}
height={viewBox.height * 5}
>
<rect
x={viewBox.x - viewBox.width * 2}
y={viewBox.y - viewBox.height * 2}
width={viewBox.width * 5}
height={viewBox.height * 5}
fill={`url(#${patternId}-dots-fade)`}
/>
</mask>
<radialGradient
id={`${patternId}-dots-fade`}
cx={viewBox.x + viewBox.width / 2}
cy={viewBox.y + viewBox.height / 2}
r={Math.max(viewBox.width, viewBox.height) * 1.5}
gradientUnits="userSpaceOnUse"
>
<stop offset="0.3" stopColor="white" />
<stop offset="1" stopColor="white" stopOpacity="0" />
</radialGradient>
<rect
data-slot="isometric-mark-floor"
data-floor="dots"
x={viewBox.x - viewBox.width * 2}
y={viewBox.y - viewBox.height * 2}
width={viewBox.width * 5}
height={viewBox.height * 5}
fill={`url(#${patternId}-dots)`}
mask={`url(#${patternId}-dots-mask)`}
/>
</>
) : floor === "checker" ? (
<path
data-slot="isometric-mark-floor"
data-floor="checker"
d={model.floorCells}
fill={`url(#${patternId}-floor)`}
/>
) : (
<path
data-slot="isometric-mark-floor"
data-floor={floor === "lines" ? "lines" : "grid"}
d={floor === "lines" ? model.floorLines : model.floor}
stroke={`url(#${patternId}-floor)`}
strokeWidth={floor === "lines" ? unit * 1.5 : unit}
/>
)}
</>
) : null}
{guides ? (
<path
data-slot="isometric-mark-guides"
d={model.guides}
stroke="var(--guide)"
strokeWidth={unit}
strokeDasharray={`${unit * 4} ${unit * 2}`}
/>
) : null}
<g
strokeWidth={unit}
strokeLinejoin="round"
strokeLinecap="round"
fillRule="evenodd"
>
{model.walls.map((wall, index) => (
<IsometricMarkWall
key={index}
wall={wall}
height={height}
gradientId={gradientId}
heightScale={model.heightScale}
/>
))}
<motion.g data-slot="isometric-mark-top" style={{ y: topOffset }}>
{model.tops.some((top) => top.tone) ? (
// 有色调的形状逐块画:淡色底 + 同一张材质纹理 + 同色描边
model.tops.map((top, index) => (
<g key={index}>
<path
d={top.d}
fill={
top.tone
? `color-mix(in oklab, ${top.tone} var(--tone-top), var(--top))`
: "var(--top)"
}
/>
<path d={top.d} fill={`url(#${patternId})`} />
<path
d={top.d}
stroke={
top.tone
? `color-mix(in oklab, ${top.tone} var(--tone-line), var(--background))`
: "var(--stroke)"
}
/>
</g>
))
) : (
<>
<path d={model.top} fill="var(--top)" />
<path d={model.top} fill={`url(#${patternId})`} />
<path d={model.top} stroke="var(--stroke)" />
</>
)}
<path d={model.top} stroke={`url(#${gradientId})`} />
</motion.g>
</g>
</svg>
)
}
export { IsometricMark, IsometricMarkTuningContext, useIsometricMarkMuted }
export type {
IsometricMarkProps,
IsometricMarkReport,
IsometricMarkTuning,
IsometricFloor,
IsometricMaterial,
IsometricPoint,
IsometricShape,
}
/**
* 等轴测挤出的纯几何计算:只依赖数学,不碰 React,方便单测与复用。
*
* 坐标约定:平面 logo 的坐标 (x, y),y 向下;挤出方向为 z(向上)。
* 投影:X = (x - y)·cos30°,Y = (x + y)·sin30° - z。
* 在这个投影下,观察者位于 (+x, +y, +z) 一侧,朝向 (1, 1) 的墙面可见。
*/
/** 平面坐标点 `[x, y]` */
type IsometricPoint = readonly [number, number]
/** 一个平面形状:一圈外轮廓加若干个孔,孔会被挤成通孔。 */
type IsometricShape = {
outer: readonly IsometricPoint[]
holes?: readonly (readonly IsometricPoint[])[]
/** 这块形状的色调(任意 CSS 颜色):顶面淡淡着色、线条取同色;不传为单色线稿 */
tone?: string
}
/** 墙面底边在屏幕上的两个端点(z = 0),顶边由当前高度决定。 */
type IsometricWall = {
x0: number
y0: number
x1: number
y1: number
tone?: string
}
type IsometricModel = {
viewBox: { x: number; y: number; width: number; height: number }
/** 线宽、斜纹、虚线等“一个像素”对应的 viewBox 长度,随图形尺寸等比缩放 */
unit: number
/** 实际使用的挤出高度(props 未传时为自动值) */
depth: number
/** 顶面路径(z = depth),外轮廓与孔都在其中,需配合 evenodd */
top: string
/** 按形状拆开的顶面,带各自的色调 */
tops: { d: string; tone?: string }[]
/** 可见侧墙,按从远到近排好序 */
walls: IsometricWall[]
/** 背景辅助线路径(z = 0) */
guides: string
/** 地面网格路径(z = 0),与辅助线同方向 */
floor: string
/** 只有第一个方向的地面横线 */
floorLines: string
/** 地面格点(两组格线的交点) */
floorDots: string
/** 棋盘格:隔一格填一格 */
floorCells: string
/** 挤出高度 1 在屏幕上对应的竖直长度(随俯仰角变化,标准等轴测为 1) */
heightScale: number
}
const COS30 = Math.cos(Math.PI / 6)
const SIN30 = 0.5
const EPSILON = 1e-6
/** 自动挤出高度占图形平面尺寸的比例 */
const AUTO_DEPTH_RATIO = 0.12
const fmt = (n: number) => String(Math.round(n * 100) / 100)
/** 标准等轴测的俯仰角:视线与地面成 arctan(1/√2) ≈ 35.26° */
const ISOMETRIC_ELEVATION = (Math.atan(1 / Math.SQRT2) * 180) / Math.PI
/**
* 投影系数:水平方位固定 45°(两条地面轴左右对称),俯仰角可调。
* 地面方向的竖直压缩 ky = sinE·√2·cos30°,高度方向 kz = cosE·√2·cos30°;
* 俯仰角取 35.26° 时 ky = 0.5、kz = 1,正好是标准等轴测。
* 俯仰角越大越接近俯视(地面越“方”、厚度越薄),越小越接近平视(地面越扁、厚度越高)。
*/
let KY = SIN30
let KZ = 1
function setElevation(degrees: number) {
const e = (Math.min(Math.max(degrees, 5), 85) * Math.PI) / 180
KY = Math.sin(e) * Math.SQRT2 * COS30
KZ = Math.cos(e) * Math.SQRT2 * COS30
}
function project(x: number, y: number, z = 0): [number, number] {
return [(x - y) * COS30, (x + y) * KY - z * KZ]
}
/** 去掉首尾重复的闭合点,统一成不含重复端点的环。 */
function cleanRing(ring: readonly IsometricPoint[]): IsometricPoint[] {
const points = [...ring]
const first = points[0]
const last = points[points.length - 1]
if (points.length > 1 && first[0] === last[0] && first[1] === last[1]) {
points.pop()
}
return points
}
function signedArea(ring: readonly IsometricPoint[]) {
let sum = 0
for (let i = 0; i < ring.length; i++) {
const [x0, y0] = ring[i]
const [x1, y1] = ring[(i + 1) % ring.length]
sum += x0 * y1 - x1 * y0
}
return sum / 2
}
/** 外轮廓取正面积、孔取负面积,这样所有环的“实体外侧”法线公式一致。 */
function orient(ring: IsometricPoint[], positive: boolean) {
return signedArea(ring) > 0 === positive ? ring : [...ring].reverse()
}
function ringPath(ring: readonly IsometricPoint[], z: number) {
return (
ring
.map(([x, y], i) => {
const [px, py] = project(x, y, z)
return `${i === 0 ? "M" : "L"}${fmt(px)} ${fmt(py)}`
})
.join("") + "Z"
)
}
type Segment = {
/** 平面坐标,用于排序 */
px: number
py: number
qx: number
qy: number
tone?: string
}
/** 排序键:沿观察方向的“深度”,越大越靠近观察者。 */
const depthOf = (s: Segment) => (s.px + s.py + s.qx + s.qy) / 4
/**
* 判断两面墙的前后关系:-1 表示 a 在 b 后面(先画 a),1 相反,0 表示互不遮挡。
* 墙在屏幕上只有 X = x - y 方向的范围,范围不重叠就不会互相遮挡;
* 重叠区间内两线段不相交,因此取区间中点比较 x + y 即可。
*/
function compareSegments(a: Segment, b: Segment) {
const ua0 = a.px - a.py
const ua1 = a.qx - a.qy
const ub0 = b.px - b.py
const ub1 = b.qx - b.qy
const lo = Math.max(Math.min(ua0, ua1), Math.min(ub0, ub1))
const hi = Math.min(Math.max(ua0, ua1), Math.max(ub0, ub1))
if (hi - lo < EPSILON) return 0
const u = (lo + hi) / 2
const depthAt = (s: Segment, u0: number, u1: number) => {
const t = (u - u0) / (u1 - u0)
return s.px + s.py + t * (s.qx + s.qy - s.px - s.py)
}
const diff = depthAt(a, ua0, ua1) - depthAt(b, ub0, ub1)
if (Math.abs(diff) < EPSILON) return 0
return diff < 0 ? -1 : 1
}
/**
* 画家算法的顺序:先按“谁挡住谁”做拓扑排序,凹多边形也能排对;
* 理论上不会出现环,真遇到时退回按深度最小者优先,保证一定有结果。
* 顺序只取决于平面形状,与挤出高度无关,所以按下动画时不用重排。
*/
function sortBackToFront(segments: Segment[]) {
const n = segments.length
const after: number[][] = Array.from({ length: n }, () => [])
const blockers = new Array<number>(n).fill(0)
for (let i = 0; i < n; i++) {
for (let j = i + 1; j < n; j++) {
const order = compareSegments(segments[i], segments[j])
if (order < 0) {
after[i].push(j)
blockers[j]++
} else if (order > 0) {
after[j].push(i)
blockers[i]++
}
}
}
const done = new Array<boolean>(n).fill(false)
const sorted: Segment[] = []
for (let k = 0; k < n; k++) {
let pick = -1
for (let i = 0; i < n; i++) {
if (done[i] || blockers[i] > 0) continue
if (pick < 0 || depthOf(segments[i]) < depthOf(segments[pick])) pick = i
}
if (pick < 0) {
for (let i = 0; i < n; i++) {
if (done[i]) continue
if (pick < 0 || depthOf(segments[i]) < depthOf(segments[pick])) pick = i
}
}
done[pick] = true
sorted.push(segments[pick])
for (const next of after[pick]) blockers[next]--
}
return sorted
}
function buildIsometricModel(
shapes: readonly IsometricShape[],
depthOption?: number,
guideMode: "box" | "edges" = "box",
elevation: number = ISOMETRIC_ELEVATION,
): IsometricModel {
setElevation(elevation)
const rings: {
ring: IsometricPoint[]
hole: boolean
shape: number
tone?: string
}[] = []
shapes.forEach((shape, index) => {
const tone = shape.tone
rings.push({
ring: orient(cleanRing(shape.outer), true),
hole: false,
shape: index,
tone,
})
for (const hole of shape.holes ?? []) {
rings.push({
ring: orient(cleanRing(hole), false),
hole: true,
shape: index,
tone,
})
}
})
// 平面包围盒,用于自动高度与辅助线
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const { ring } of rings) {
for (const [x, y] of ring) {
minX = Math.min(minX, x)
minY = Math.min(minY, y)
maxX = Math.max(maxX, x)
maxY = Math.max(maxY, y)
}
}
if (!rings.length) {
minX = minY = maxX = maxY = 0
}
const planSize = Math.max(maxX - minX, maxY - minY, 1)
const depth = Math.max(depthOption ?? planSize * AUTO_DEPTH_RATIO, 0)
// 背面剔除:法线 (ey, -ex) 与观察方向 (1, 1) 同侧才可见;
// 法线与观察方向垂直的墙在屏幕上是一条竖线,面积为零,也不画。
const segments: Segment[] = []
for (const { ring, tone } of rings) {
for (let i = 0; i < ring.length; i++) {
const [px, py] = ring[i]
const [qx, qy] = ring[(i + 1) % ring.length]
if (qy - py - (qx - px) > EPSILON) segments.push({ px, py, qx, qy, tone })
}
}
const walls = sortBackToFront(segments).map((s) => {
const [x0, y0] = project(s.px, s.py)
const [x1, y1] = project(s.qx, s.qy)
return { x0, y0, x1, y1, tone: s.tone }
})
// 投影后的包围盒:顶面在 z = depth(最高),底边在 z = 0(最低)
let bx0 = Infinity
let by0 = Infinity
let bx1 = -Infinity
let by1 = -Infinity
for (const { ring } of rings) {
for (const [x, y] of ring) {
const [px, py] = project(x, y)
bx0 = Math.min(bx0, px)
bx1 = Math.max(bx1, px)
by0 = Math.min(by0, py - depth * KZ)
by1 = Math.max(by1, py)
}
}
if (!rings.length) {
bx0 = by0 = bx1 = by1 = 0
}
const size = Math.max(bx1 - bx0, by1 - by0, 1)
const margin = size * 0.02
// 辅助线:
// - "box":包围盒四条边沿两条等轴测轴延长(横平竖直的图形最合适);
// - "edges":沿图形自己的主方向——按边长统计各方向,取最长的两个,每个方向画两条外侧切线并延长。
// 斜向的图形(如一组斜杠)辅助线会贴着它的长边与齐平的端点。
const reach = planSize * 1.2
const line = (x0: number, y0: number, x1: number, y1: number) => {
const [ax, ay] = project(x0, y0)
const [bx, by] = project(x1, y1)
return `M${fmt(ax)} ${fmt(ay)}L${fmt(bx)} ${fmt(by)}`
}
const weights = new Map<number, { ux: number; uy: number; length: number }>()
for (const { ring } of rings) {
for (let i = 0; i < ring.length; i++) {
const [px, py] = ring[i]
const [qx, qy] = ring[(i + 1) % ring.length]
const length = Math.hypot(qx - px, qy - py)
if (length < EPSILON) continue
// 方向取 [0°, 180°),按 0.5° 分桶,避免浮点误差把同一方向拆开
let angle = (Math.atan2(qy - py, qx - px) * 180) / Math.PI
angle = ((angle % 180) + 180) % 180
const key = Math.round(angle * 2) % 360
const entry = weights.get(key)
if (entry) entry.length += length
else {
const r = (key / 2) * (Math.PI / 180)
weights.set(key, { ux: Math.cos(r), uy: Math.sin(r), length })
}
}
}
const directions =
guideMode === "edges"
? [...weights.values()].sort((a, b) => b.length - a.length).slice(0, 2)
: [
{ ux: 1, uy: 0, length: 0 },
{ ux: 0, uy: 1, length: 0 },
]
const points = rings.flatMap(({ ring }) => ring)
// 地面网格:与辅助线同两个方向、同一个平面(z = 0),目标是图形的每条边都落在格线上。
// 每个方向单独算:所有顶点在法线方向上的距离若有足够大的公约数(如标志左端切口
// 0 / 78 / 156 / 416 的公约数 26),就按它等距铺;否则每条边各画一条线,外侧按平均间距延伸。
const floorTarget = planSize / 10
const floorReach = planSize * 0.9
const approxGcd = (a: number, b: number): number => {
const tolerance = planSize / 400
while (b > tolerance) [a, b] = [b, a % b]
return a
}
// 每个方向算出一组格线位置(法线方向上的绝对偏移),地面的各种材质都由这两组格线组合而成
const families = directions.map(({ ux, uy }) => {
const nx = -uy
const ny = ux
let lo = Infinity
let hi = -Infinity
let a0 = Infinity
let a1 = -Infinity
for (const [x, y] of points) {
lo = Math.min(lo, x * nx + y * ny)
hi = Math.max(hi, x * nx + y * ny)
a0 = Math.min(a0, x * ux + y * uy)
a1 = Math.max(a1, x * ux + y * uy)
}
const span = hi - lo
const offsets = [...new Set(points.map(([x, y]) => x * nx + y * ny - lo))]
.sort((p, q) => p - q)
.filter((d, i, all) => i === 0 || d - all[i - 1] > planSize / 400)
let g = 0
for (const d of offsets) g = approxGcd(g, d)
let ds: number[]
let step: number
if (span > EPSILON && g >= span / 24) {
// 所有边的间距有公约数:按公约数等距铺,图形里的每条边都落在格线上;
// 公约数太大(格子太稀)时再等分,等分后边依然在格线上
step = g / Math.max(1, Math.round(g / floorTarget))
ds = []
for (let d = 0; d <= span + EPSILON; d += step) ds.push(d)
} else {
// 没有合适的公约数:每条边各画一条格线(像构造图那样),外侧按平均间距延伸
ds = offsets
step = offsets.length > 1 ? span / (offsets.length - 1) : floorTarget
}
const all = [...ds]
for (let d = -step; d >= -floorReach; d -= step) all.push(d)
for (let d = span + step; d <= span + floorReach; d += step) all.push(d)
all.sort((p, q) => p - q)
return {
ux,
uy,
nx,
ny,
offsets: all.map((d) => lo + d),
t0: a0 - floorReach,
t1: a1 + floorReach,
}
})
const lineOf = (f: (typeof families)[number], off: number) =>
line(
off * f.nx + f.t0 * f.ux,
off * f.ny + f.t0 * f.uy,
off * f.nx + f.t1 * f.ux,
off * f.ny + f.t1 * f.uy,
)
const floor = families
.flatMap((f) => f.offsets.map((off) => lineOf(f, off)))
.join("")
const floorLines = families[0]
? families[0].offsets.map((off) => lineOf(families[0], off)).join("")
: ""
// 两组格线的交点:解 n1·p = d1、n2·p = d2
const cross = (d1: number, d2: number): [number, number] | null => {
const [f1, f2] = families
const det = f1.nx * f2.ny - f1.ny * f2.nx
if (Math.abs(det) < EPSILON) return null
return [(d1 * f2.ny - f1.ny * d2) / det, (f1.nx * d2 - d1 * f2.nx) / det]
}
let floorDots = ""
let floorCells = ""
if (families.length === 2) {
const [f1, f2] = families
const r = planSize / 130
f1.offsets.forEach((d1) => {
f2.offsets.forEach((d2) => {
const p = cross(d1, d2)
if (!p) return
const [x, y] = project(p[0], p[1])
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`
})
})
for (let i = 0; i < f1.offsets.length - 1; i++) {
for (let j = 0; j < f2.offsets.length - 1; j++) {
if ((i + j) % 2) continue
const corners = [
cross(f1.offsets[i], f2.offsets[j]),
cross(f1.offsets[i + 1], f2.offsets[j]),
cross(f1.offsets[i + 1], f2.offsets[j + 1]),
cross(f1.offsets[i], f2.offsets[j + 1]),
]
if (corners.some((c) => !c)) continue
floorCells +=
corners
.map((c, k) => {
const [x, y] = project(c![0], c![1])
return `${k ? "L" : "M"}${fmt(x)} ${fmt(y)}`
})
.join("") + "Z"
}
}
}
const guides = directions
.flatMap(({ ux, uy }) => {
const nx = -uy
const ny = ux
let lo = Infinity
let hi = -Infinity
let a0 = Infinity
let a1 = -Infinity
for (const [x, y] of points) {
const d = x * nx + y * ny
const t = x * ux + y * uy
lo = Math.min(lo, d)
hi = Math.max(hi, d)
a0 = Math.min(a0, t)
a1 = Math.max(a1, t)
}
return [lo, hi].map((d) => {
const t0 = a0 - reach
const t1 = a1 + reach
return line(
d * nx + t0 * ux,
d * ny + t0 * uy,
d * nx + t1 * ux,
d * ny + t1 * uy,
)
})
})
.join("")
const viewBox = {
x: bx0 - margin,
y: by0 - margin,
width: bx1 - bx0 + margin * 2,
height: by1 - by0 + margin * 2,
}
return {
viewBox,
unit: size / 500,
depth,
top: rings.map(({ ring }) => ringPath(ring, depth)).join(""),
tops: shapes.map((shape, index) => ({
d: rings
.filter((r) => r.shape === index)
.map(({ ring }) => ringPath(ring, depth))
.join(""),
tone: shape.tone,
})),
walls,
guides,
floor,
floorLines,
floorDots,
floorCells,
heightScale: KZ,
}
}
/** 高度为 h 时一面墙的路径:底边固定在 z = 0,顶边抬到 z = h。 */
function wallPath(
{ x0, y0, x1, y1 }: IsometricWall,
height: number,
heightScale = 1,
) {
const h = height * heightScale
return `M${fmt(x0)} ${fmt(y0 - h)}L${fmt(x1)} ${fmt(y1 - h)}L${fmt(x1)} ${fmt(y1)}L${fmt(x0)} ${fmt(y0)}Z`
}
/**
* 投影前把平面形状绕包围盒中心旋转 `degrees` 度(y 向下坐标系里顺时针为正)。
* 建议取 90 的整数倍:这样图形的横竖边始终沿等轴测斜轴,仍是一块平放的板;
* 其他角度(如 -45°)会让顶面看起来像一堵立着的墙。
*/
function rotateShapes(
shapes: readonly IsometricShape[],
degrees: number,
): IsometricShape[] {
if (!degrees) return shapes.map((s) => ({ ...s }))
const all = shapes.flatMap((s) => [...s.outer, ...(s.holes ?? []).flat()])
const xs = all.map(([x]) => x)
const ys = all.map(([, y]) => y)
const cx = (Math.min(...xs) + Math.max(...xs)) / 2
const cy = (Math.min(...ys) + Math.max(...ys)) / 2
const r = (degrees * Math.PI) / 180
const cos = Math.cos(r)
const sin = Math.sin(r)
const turn = (ring: readonly IsometricPoint[]): IsometricPoint[] =>
ring.map(([x, y]) => [
cx + (x - cx) * cos - (y - cy) * sin,
cy + (x - cx) * sin + (y - cy) * cos,
])
return shapes.map((s) => ({
outer: turn(s.outer),
holes: s.holes?.map(turn),
tone: s.tone,
}))
}
export { buildIsometricModel, ISOMETRIC_ELEVATION, rotateShapes, wallPath }
export type { IsometricModel, IsometricPoint, IsometricShape, IsometricWall }
"use client"
import * as React from "react"
/**
* 按下 / 松开的音效:用 Web Audio 现场合成,不需要音频文件。
* 按下是一声清脆的「咔」(短促的下滑正弦 + 高频带通噪声做触感,只留少量低频厚度),松开是更轻、更高的一声「嗒」。
* AudioContext 在第一次按下(用户手势)时才创建,满足浏览器的自动播放限制。
*/
let context: AudioContext | null = null
function audio() {
if (typeof window === "undefined") return null
if (!context) {
const Ctor =
window.AudioContext ??
(window as unknown as { webkitAudioContext?: typeof AudioContext })
.webkitAudioContext
if (!Ctor) return null
context = new Ctor()
}
if (context.state === "suspended") void context.resume()
return context
}
function tone(from: number, to: number, duration: number, volume: number) {
const ctx = audio()
if (!ctx) return
const now = ctx.currentTime
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.type = "sine"
osc.frequency.setValueAtTime(from, now)
osc.frequency.exponentialRampToValueAtTime(to, now + duration)
gain.gain.setValueAtTime(0.0001, now)
gain.gain.exponentialRampToValueAtTime(volume, now + 0.004)
gain.gain.exponentialRampToValueAtTime(0.0001, now + duration)
osc.connect(gain).connect(ctx.destination)
osc.start(now)
osc.stop(now + duration + 0.02)
}
function tick(frequency: number, duration: number, volume: number) {
const ctx = audio()
if (!ctx) return
const now = ctx.currentTime
const length = Math.ceil(ctx.sampleRate * duration)
const buffer = ctx.createBuffer(1, length, ctx.sampleRate)
const data = buffer.getChannelData(0)
for (let i = 0; i < length; i++)
data[i] = (Math.random() * 2 - 1) * (1 - i / length) ** 3
const source = ctx.createBufferSource()
source.buffer = buffer
const filter = ctx.createBiquadFilter()
filter.type = "bandpass"
filter.frequency.value = frequency
filter.Q.value = 1.2
const gain = ctx.createGain()
gain.gain.value = volume
source.connect(filter).connect(gain).connect(ctx.destination)
source.start(now)
}
const mix = (a: number, b: number, t: number) => a + (b - a) * t
/**
* 音色在「闷」与「脆」之间连续插值:0 是低沉的「咚」(长低频、暗噪声),
* 1 是清脆的「咔」(短低频、加一层高频短音、亮噪声)。默认 0.6。
*/
function playPress(crisp = 0.6) {
const t = Math.min(Math.max(crisp, 0), 1)
tone(
mix(150, 340, t),
mix(62, 150, t),
mix(0.14, 0.06, t),
mix(0.22, 0.12, t),
)
if (t > 0.05) tone(mix(900, 1600, t), mix(700, 1000, t), 0.03, 0.08 * t)
tick(mix(1600, 4200, t), mix(0.022, 0.013, t), mix(0.1, 0.22, t))
}
function playRelease(crisp = 0.6) {
const t = Math.min(Math.max(crisp, 0), 1)
tone(
mix(260, 700, t),
mix(180, 520, t),
mix(0.06, 0.035, t),
mix(0.08, 0.06, t),
)
tick(mix(3000, 6000, t), mix(0.012, 0.009, t), mix(0.05, 0.13, t))
}
/* ---------- 静音偏好:存在 localStorage,全站带音效的组件共用 ---------- */
const STORAGE_KEY = "travinsy:sound:muted"
/** 旧版本用过的键:读到新键没写过时按它迁移,保证升级后偏好不丢。 */
const LEGACY_KEYS = ["isometric-mark:muted"]
const WATCHED_KEYS = [STORAGE_KEY, ...LEGACY_KEYS]
/**
* 同页广播:`storage` 事件只发给「其他」标签页,本页写 localStorage 不会触发它。
* 带音效的组件各自有一份静音模块(互不 import),所以约定用同一个事件名互相通知。
*/
const MUTED_EVENT = "travinsy:sound-muted-change"
const listeners = new Set<() => void>()
function readMuted() {
try {
const current = window.localStorage.getItem(STORAGE_KEY)
if (current !== null) return current === "1"
return LEGACY_KEYS.some((key) => window.localStorage.getItem(key) === "1")
} catch {
return false
}
}
function setMuted(muted: boolean) {
try {
window.localStorage.setItem(STORAGE_KEY, muted ? "1" : "0")
} catch {
// 隐私模式等存不了时只在本页生效
}
listeners.forEach((listener) => listener())
// 通知同页其他带音效的组件
window.dispatchEvent(new Event(MUTED_EVENT))
}
function subscribe(listener: () => void) {
listeners.add(listener)
const notify = () => listener()
const onStorage = (event: StorageEvent) => {
if (event.key === null || WATCHED_KEYS.includes(event.key)) notify()
}
window.addEventListener("storage", onStorage)
window.addEventListener(MUTED_EVENT, notify)
return () => {
listeners.delete(listener)
window.removeEventListener("storage", onStorage)
window.removeEventListener(MUTED_EVENT, notify)
}
}
/** 读取 / 切换音效静音。服务端与水合阶段按未静音渲染,挂载后再读本地偏好。 */
function useIsometricMarkMuted() {
const muted = React.useSyncExternalStore(subscribe, readMuted, () => false)
return [muted, setMuted] as const
}
export { playPress, playRelease, useIsometricMarkMuted }