# 点阵地球 Dot Globe > 小点拼成的地球慢慢自转,可以拖着转,城市之间有光线飞过。 分类:动效 · 角色:主角(占一整块、抢全部注意力,一页最多一个) [全部组件和流程](https://www.dachuiai.com/llms.txt) · [动效选型规则](https://www.dachuiai.com/guides/motion.md) ## 给 AI:用户把这个组件发给了你 1. 先看用户的项目:框架、页面类型、现在的风格、手机还是电脑为主。 2. 对照下面的「适合 / 不适合」判断合不合适。不合适就直接说,并从[动效选型规则](https://www.dachuiai.com/guides/motion.md)或[全部组件](https://www.dachuiai.com/llms.txt)里另挑一个。 3. 合适就按「装进项目」装好,示例里的文字和图片换成用户自己的。 4. 告诉用户用了什么、放在哪个文件、以后想改快慢、颜色、大小改哪个参数。 ## 什么时候用 - 适合:讲全球覆盖、多地区节点、跨境物流和网络的 SaaS 或官网。 - 不适合:只在本地或一个城市做生意(地球只是装饰);后台。 - 适合的网站:SaaS、官网、活动页 - 放在:首屏、内容区 - 手机上:好用:手指能拖着转;尺寸按屏宽缩小。 - 性能代价:中:Canvas 每帧画几千个点,滚出屏幕自动暂停。 - 常搭配:[数字滚动 Count Up](https://www.dachuiai.com/c/count-up.md)、[乱码解码 Scramble Text](https://www.dachuiai.com/c/scramble-text.md) ## 装进项目 - 写法:React 18 以上 + TypeScript + Tailwind CSS v4,一个文件就能用,不引用别的组件。 - 依赖:只用 react,不用另外安装 - 文件:原样存成 `dot-globe.tsx`(比如 `src/components/dot-globe.tsx`)。能跑命令就下载[原文](https://www.dachuiai.com/code/dot-globe.tsx),比手抄准:`curl -fsSL -o src/components/dot-globe.tsx https://www.dachuiai.com/code/dot-globe.tsx`(Windows 写 `curl.exe`)。下载完看一眼第一行是 `import`,不是网页。 - 导出:`DotGlobe` 是可以直接用的部分;最后的 `export default` 是演示示例,只作参考。 - 注意:links 传城市之间的连线(经纬度);color 是点的颜色,写成 "r,g,b"。 - `links` 要写成组件外面的常量,或者用 useMemo 包起来:每次渲染都传一个新写的数组,组件会以为内容变了,从头重来。 - 保留代码里 `prefers-reduced-motion` 的处理:用户开了「减弱动态效果」时内容照样完整显示。 - Next.js(有 app 文件夹):文件第一行加 `'use client';`。 - 用 JavaScript 不用 TypeScript:Vite 和 Next.js 都能直接用 .tsx 文件;也可以删掉类型标注,改名 .jsx。 - 没有 Tailwind:先装 Tailwind CSS v4(Vite 项目 `npm i tailwindcss @tailwindcss/vite`,vite 配置的 plugins 里加 `tailwindcss()`,入口引入的那个 CSS 文件(一般是 `src/index.css`)第一行写 `@import "tailwindcss";`),或者把类名改写成项目用的样式。 - 已经装了 Tailwind v3:别为了一个组件升级。类名大多通用,少数 v4 写法要换,比如焦点框 `outline-2` 在 v3 里要写成 `outline outline-2`。 - 不是 React(Vue、Svelte、小程序、纯 HTML):照着代码的做法改写,时长、曲线、参数保持一样。Wix、有赞这类建站平台装不进代码,告诉用户去平台自带的动效设置里找。 ## 参数 | 参数 | 默认值 | 作用 | | --- | --- | --- | | `links` | `NONE` | 城市之间的光线:每条 from、to 两个经纬度 | | `color` | `'125,211,252'` | 点的颜色,写成 "r,g,b" | | `accent` | `'#FFB86B'` | 光线和城市点的颜色 | | `className` | `''` | 加在最外层的类名,用来调大小、位置、间距 | ## 你可以说 > 首屏放一个 Dot Globe 点阵地球:用小点拼成的地球慢慢自转,可以拖着转,城市之间有光线飞过。 ## 完整代码 ```tsx import {useEffect, useRef} from 'react'; /* --------------------------------------------------------------- * DotGlobe 点阵地球 * 用几千个小点拼成的地球,慢慢自转,可以拖着转,城市之间有光线飞过。 * 大陆是一组很粗略的经纬度多边形,判断每个点在不在陆地上。 * 纯 Canvas,不需要 3D 库。滚出屏幕时自动暂停。 * ------------------------------------------------------------- */ type LonLat = [number, number]; export type Link = {from: LonLat; to: LonLat}; const LAND: LonLat[][] = [ [[-168, 66], [-156, 71], [-125, 70], [-95, 72], [-82, 73], [-62, 60], [-55, 52], [-66, 44], [-75, 38], [-81, 31], [-81, 25], [-90, 29], [-97, 26], [-97, 19], [-88, 16], [-83, 10], [-79, 8], [-92, 14], [-105, 20], [-114, 29], [-122, 37], [-125, 48], [-135, 58], [-150, 60], [-165, 55]], [[-52, 60], [-22, 70], [-18, 80], [-60, 83], [-72, 77], [-55, 68]], [[-79, 8], [-62, 10], [-50, 0], [-35, -6], [-39, -18], [-48, -26], [-58, -38], [-65, -47], [-70, -54], [-75, -46], [-72, -30], [-70, -18], [-80, -5]], [[-9, 37], [-9, 43], [-2, 44], [-5, 48], [3, 51], [8, 54], [5, 60], [14, 68], [28, 71], [45, 68], [70, 73], [105, 77], [140, 73], [180, 68], [178, 62], [160, 60], [155, 52], [140, 54], [135, 44], [128, 38], [122, 30], [118, 23], [108, 18], [109, 11], [103, 2], [99, 10], [94, 17], [90, 22], [80, 15], [77, 8], [72, 20], [62, 25], [57, 26], [50, 30], [56, 24], [59, 22], [52, 16], [43, 13], [35, 28], [33, 31], [36, 36], [27, 37], [24, 40], [22, 37], [16, 38], [12, 44], [8, 43], [3, 42], [0, 38], [-6, 36]], [[-16, 15], [-17, 22], [-10, 30], [-6, 35], [10, 37], [20, 32], [32, 31], [35, 25], [38, 18], [43, 12], [51, 11], [45, 2], [40, -5], [40, -15], [35, -24], [27, -33], [20, -35], [15, -25], [12, -15], [13, -5], [9, 4], [-5, 5], [-12, 8]], [[114, -22], [122, -18], [131, -12], [137, -14], [142, -11], [146, -18], [153, -26], [150, -37], [140, -38], [130, -32], [116, -34]], [[44, -16], [50, -15], [47, -25], [44, -24]], [[130, 32], [136, 34], [141, 37], [142, 43], [139, 38], [132, 34]], [[-5, 50], [1, 52], [-2, 58], [-6, 56]], [[96, 5], [105, -5], [115, -8], [120, -2], [110, 2]], [[167, -46], [174, -41], [178, -38], [172, -42]], [[-24, 64], [-14, 64], [-16, 66], [-22, 66]], ]; const inside = (p: LonLat, poly: LonLat[]) => { let c = false; for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { const [xi, yi] = poly[i], [xj, yj] = poly[j]; if (yi > p[1] !== yj > p[1] && p[0] < ((xj - xi) * (p[1] - yi)) / (yj - yi) + xi) c = !c; } return c; }; const toXYZ = ([lon, lat]: LonLat): [number, number, number] => { const a = (lon * Math.PI) / 180, b = (lat * Math.PI) / 180; return [Math.cos(b) * Math.sin(a), Math.sin(b), Math.cos(b) * Math.cos(a)]; }; // 斐波那契球:均匀地在球面上撒点,只留下落在陆地上的 function landPoints(total = 16000) { const out: number[] = []; const g = Math.PI * (3 - Math.sqrt(5)); for (let i = 0; i < total; i++) { const y = 1 - (i / (total - 1)) * 2, r = Math.sqrt(1 - y * y), t = g * i; const x = Math.cos(t) * r, z = Math.sin(t) * r; const ll: LonLat = [(Math.atan2(x, z) * 180) / Math.PI, (Math.asin(y) * 180) / Math.PI]; if (LAND.some((poly) => inside(ll, poly))) out.push(x, y, z); } return new Float32Array(out); } const NONE: Link[] = []; export function DotGlobe({links = NONE, color = '125,211,252', accent = '#FFB86B', className = ''}: {links?: Link[]; color?: string; accent?: string; className?: string}) { const ref = useRef(null); useEffect(() => { const canvas = ref.current!; const ctx = canvas.getContext('2d')!; const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches; const pts = landPoints(); const arcs = links.map((l, i) => ({a: toXYZ(l.from), b: toXYZ(l.to), t: i / Math.max(1, links.length)})); const s = {yaw: -1.9, pitch: 0.32, vy: reduce ? 0 : 0.0028, vp: 0, drag: false, lx: 0, ly: 0}; let W = 0, H = 0, R = 0, raf = 0, running = false; const project = (x: number, y: number, z: number) => { const cy = Math.cos(s.yaw), sy = Math.sin(s.yaw), cp = Math.cos(s.pitch), sp = Math.sin(s.pitch); const x1 = x * cy + z * sy, z1 = -x * sy + z * cy; const y1 = y * cp - z1 * sp, z2 = y * sp + z1 * cp; return [W / 2 + x1 * R, H / 2 - y1 * R, z2]; }; const draw = () => { ctx.clearRect(0, 0, W, H); const halo = ctx.createRadialGradient(W / 2, H / 2, R * 0.9, W / 2, H / 2, R * 1.32); halo.addColorStop(0, `rgba(${color},.2)`); halo.addColorStop(1, `rgba(${color},0)`); ctx.fillStyle = halo; ctx.fillRect(0, 0, W, H); const body = ctx.createRadialGradient(W / 2 - R * 0.35, H / 2 - R * 0.4, R * 0.1, W / 2, H / 2, R); body.addColorStop(0, 'rgba(30,50,90,.9)'); body.addColorStop(1, 'rgba(8,12,26,.96)'); ctx.fillStyle = body; ctx.beginPath(); ctx.arc(W / 2, H / 2, R, 0, 7); ctx.fill(); for (let i = 0; i < pts.length; i += 3) { const [x, y, z] = project(pts[i], pts[i + 1], pts[i + 2]); if (z < 0) continue; ctx.fillStyle = `rgba(${color},${0.24 + z * 0.76})`; const d = 0.9 + z * 1.2; ctx.fillRect(x - d / 2, y - d / 2, d, d); } // 城市之间的光线:沿球面插值,中间抬高一点 ctx.lineCap = 'round'; for (const a of arcs) { if (!reduce) a.t = (a.t + 0.0035) % 1; const head = a.t * 1.35, tail = head - 0.34, N = 34; for (const [p, on] of [[a.a, true], [a.b, a.t > 0.6]] as const) { const [x, y, z] = project(...p); if (z > 0 && on) { ctx.fillStyle = accent; ctx.beginPath(); ctx.arc(x, y, 2.6, 0, 7); ctx.fill(); } } let prev: number[] | null = null; for (let k = 0; k <= N; k++) { const u = k / N; const v = [0, 1, 2].map((i) => a.a[i] * (1 - u) + a.b[i] * u); const n = Math.hypot(v[0], v[1], v[2]), lift = 1 + Math.sin(u * Math.PI) * 0.22; const q = project((v[0] / n) * lift, (v[1] / n) * lift, (v[2] / n) * lift); if (prev && u <= head && u >= tail && q[2] > 0.06) { const f = (u - tail) / Math.max(0.001, head - tail); ctx.strokeStyle = accent; ctx.globalAlpha = Math.min(1, f) * 0.95; ctx.lineWidth = 0.6 + f * 1.6; ctx.beginPath(); ctx.moveTo(prev[0], prev[1]); ctx.lineTo(q[0], q[1]); ctx.stroke(); } prev = q; } ctx.globalAlpha = 1; } }; const tick = () => { raf = requestAnimationFrame(tick); if (!s.drag) { s.yaw += s.vy; s.pitch += s.vp; s.vy += ((reduce ? 0 : 0.0028) - s.vy) * 0.03; // 松手后慢慢回到匀速自转 s.vp *= 0.92; s.pitch += (0.32 - s.pitch) * 0.008; } draw(); }; const resize = () => { const r = canvas.getBoundingClientRect(), dpr = Math.min(devicePixelRatio, 2); W = r.width; H = r.height; R = Math.min(W, H) * 0.42; canvas.width = W * dpr; canvas.height = H * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); draw(); }; const down = (e: PointerEvent) => { s.drag = true; s.lx = e.clientX; s.ly = e.clientY; canvas.setPointerCapture(e.pointerId); }; const move = (e: PointerEvent) => { if (!s.drag) return; const dx = (e.clientX - s.lx) * 0.006, dy = (e.clientY - s.ly) * 0.006; s.lx = e.clientX; s.ly = e.clientY; s.yaw += dx; s.pitch = Math.max(-1.2, Math.min(1.2, s.pitch + dy)); s.vy = dx; s.vp = dy; if (reduce) draw(); }; const up = () => { s.drag = false; }; const ro = new ResizeObserver(resize); ro.observe(canvas); const io = new IntersectionObserver(([e]) => { if (e.isIntersecting && !running) { running = true; raf = requestAnimationFrame(tick); } else if (!e.isIntersecting) { running = false; cancelAnimationFrame(raf); } }); if (!reduce) io.observe(canvas); canvas.addEventListener('pointerdown', down); canvas.addEventListener('pointermove', move); canvas.addEventListener('pointerup', up); canvas.addEventListener('pointercancel', up); return () => { cancelAnimationFrame(raf); ro.disconnect(); io.disconnect(); canvas.removeEventListener('pointerdown', down); canvas.removeEventListener('pointermove', move); canvas.removeEventListener('pointerup', up); canvas.removeEventListener('pointercancel', up); }; }, [links, color, accent]); return ; } /* ---------------- 示例:全球节点 ---------------- */ const CITY: Record = {北京: [116, 40], 东京: [139, 35], 新加坡: [103, 1], 悉尼: [151, -33], 伦敦: [0, 51], 纽约: [-74, 40], 旧金山: [-122, 37], 圣保罗: [-46, -23], 迪拜: [55, 25], 开普敦: [18, -33]}; const LINKS: Link[] = [['北京', '东京'], ['北京', '新加坡'], ['新加坡', '悉尼'], ['北京', '伦敦'], ['伦敦', '纽约'], ['纽约', '旧金山'], ['旧金山', '东京'], ['纽约', '圣保罗'], ['迪拜', '新加坡'], ['伦敦', '开普敦']].map(([a, b]) => ({from: CITY[a], to: CITY[b]})); export default function DotGlobeDemo() { return (

你的用户
在世界各地

{[['180', '个边缘节点'], ['50 ms', '平均响应']].map(([v, k]) => (
{k}
{v}
))}
); } ```