feat(prototype): 2D swarm data-plane visualization
Top-down SVG view: patrol flight with separation and obstacle avoidance, mesh links appearing within radio range, pose-broadcast flashes, bulk sync transfers with live rate and cumulative up/down counters, channel hopping and battery per drone. Pure client-side simulation (Vite/React/TS).
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
AREA,
|
||||
fmtBytes,
|
||||
makeWorld,
|
||||
seed,
|
||||
tick,
|
||||
type World,
|
||||
} from "./sim";
|
||||
|
||||
const VIEW = 1000;
|
||||
|
||||
export default function App(): JSX.Element {
|
||||
const [droneCount, setDroneCount] = useState(8);
|
||||
const [speedup, setSpeedup] = useState(1);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [world, setWorld] = useState<World>(() => {
|
||||
seed(42);
|
||||
return makeWorld(8);
|
||||
});
|
||||
const raf = useRef(0);
|
||||
const last = useRef(performance.now());
|
||||
|
||||
useEffect(() => {
|
||||
seed(42);
|
||||
setWorld(makeWorld(droneCount));
|
||||
}, [droneCount]);
|
||||
|
||||
useEffect(() => {
|
||||
const loop = (now: number): void => {
|
||||
const dt = Math.min(0.05, (now - last.current) / 1000);
|
||||
last.current = now;
|
||||
if (!paused && dt > 0) {
|
||||
setWorld((w) => tick(w, dt * speedup));
|
||||
}
|
||||
raf.current = requestAnimationFrame(loop);
|
||||
};
|
||||
raf.current = requestAnimationFrame(loop);
|
||||
return () => cancelAnimationFrame(raf.current);
|
||||
}, [paused, speedup]);
|
||||
|
||||
const activeLinks = useMemo(
|
||||
() => [...world.links.values()].sort((a, b) => b.rate - a.rate),
|
||||
[world.links],
|
||||
);
|
||||
const busiest = activeLinks.slice(0, 6);
|
||||
|
||||
return (
|
||||
<div style={styles.shell}>
|
||||
<header style={styles.header}>
|
||||
<div>
|
||||
<div style={styles.title}>SWARM HOUSE</div>
|
||||
<div style={styles.subtitle}>
|
||||
swarm data plane — pose broadcast + opportunistic bulk sync
|
||||
</div>
|
||||
</div>
|
||||
<div style={styles.controls}>
|
||||
<label style={styles.label}>
|
||||
drones
|
||||
<input
|
||||
type="range"
|
||||
min={3}
|
||||
max={24}
|
||||
value={droneCount}
|
||||
onChange={(e) => setDroneCount(Number(e.target.value))}
|
||||
/>
|
||||
<span style={styles.value}>{droneCount}</span>
|
||||
</label>
|
||||
<label style={styles.label}>
|
||||
speed
|
||||
<input
|
||||
type="range"
|
||||
min={0.5}
|
||||
max={6}
|
||||
step={0.5}
|
||||
value={speedup}
|
||||
onChange={(e) => setSpeedup(Number(e.target.value))}
|
||||
/>
|
||||
<span style={styles.value}>{speedup}x</span>
|
||||
</label>
|
||||
<button style={styles.button} onClick={() => setPaused((p) => !p)}>
|
||||
{paused ? "resume" : "pause"}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div style={styles.main}>
|
||||
<svg
|
||||
viewBox={`0 0 ${VIEW} ${VIEW}`}
|
||||
style={styles.canvas}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
<defs>
|
||||
<radialGradient id="ground" cx="50%" cy="50%" r="75%">
|
||||
<stop offset="0%" stopColor="#101826" />
|
||||
<stop offset="100%" stopColor="#0a0f18" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect width={VIEW} height={VIEW} fill="url(#ground)" />
|
||||
{gridLines()}
|
||||
|
||||
{/* Mesh links */}
|
||||
{activeLinks.map((l) => {
|
||||
const a = world.drones[l.a];
|
||||
const b = world.drones[l.b];
|
||||
if (!a || !b) return null;
|
||||
const mx = (a.pos.x + b.pos.x) / 2;
|
||||
const my = (a.pos.y + b.pos.y) / 2;
|
||||
const hot = l.rate > 20_000;
|
||||
return (
|
||||
<g key={l.key}>
|
||||
<line
|
||||
x1={sx(a.pos.x)}
|
||||
y1={sy(a.pos.y)}
|
||||
x2={sx(b.pos.x)}
|
||||
y2={sy(b.pos.y)}
|
||||
stroke={hot ? "#39d98a" : "#3d7bd9"}
|
||||
strokeWidth={hot ? 2.2 : 1}
|
||||
opacity={0.25 + l.flash * 0.75}
|
||||
/>
|
||||
{hot && (
|
||||
<text x={sx(mx)} y={sy(my) - 6} style={styles.linkLabel as never}>
|
||||
{fmtBytes(l.rate)}/s · Σ{fmtBytes(l.totalUp + l.totalDown)}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Obstacles */}
|
||||
{world.obstacles.map((ob) => (
|
||||
<g key={ob.id}>
|
||||
<circle
|
||||
cx={sx(ob.pos.x)}
|
||||
cy={sy(ob.pos.y)}
|
||||
r={(ob.radius / AREA) * VIEW}
|
||||
fill={ob.moving ? "#5a2a35" : "#2a3245"}
|
||||
stroke={ob.moving ? "#c05a6a" : "#46536e"}
|
||||
strokeWidth={1.5}
|
||||
opacity={0.85}
|
||||
/>
|
||||
{ob.moving && (
|
||||
<text x={sx(ob.pos.x)} y={sy(ob.pos.y) + 4} style={styles.obLabel as never}>
|
||||
mobile
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* Drones */}
|
||||
{world.drones.map((d) => (
|
||||
<g
|
||||
key={d.id}
|
||||
transform={`translate(${sx(d.pos.x)} ${sy(d.pos.y)}) rotate(${(d.heading * 180) / Math.PI + 90})`}
|
||||
>
|
||||
<polygon points="0,-11 7,9 0,5 -7,9" fill="#e8ecf4" stroke="#7ea0d0" strokeWidth={1} />
|
||||
<text y={24} style={styles.droneLabel as never} transform={`rotate(${-((d.heading * 180) / Math.PI + 90)})`}>
|
||||
dr-{String(d.id + 1).padStart(2, "0")} · ch{d.channel} · {d.battery.toFixed(0)}%
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
<aside style={styles.panel}>
|
||||
<Section title="Mission">
|
||||
<Stat name="sim time" value={`${world.t.toFixed(0)} s`} />
|
||||
<Stat name="drones" value={String(world.drones.length)} />
|
||||
<Stat name="active links" value={String(world.links.size)} />
|
||||
<Stat name="pose broadcasts" value={String(world.broadcasts)} />
|
||||
<Stat name="total transferred" value={fmtBytes(world.totalBytes)} />
|
||||
</Section>
|
||||
<Section title="Busiest links">
|
||||
{busiest.map((l) => (
|
||||
<div key={l.key} style={styles.linkRow}>
|
||||
<span style={styles.linkName}>
|
||||
dr-{String(l.a + 1).padStart(2, "0")} ↔ dr-{String(l.b + 1).padStart(2, "0")}
|
||||
</span>
|
||||
<span style={styles.linkStat}>
|
||||
{fmtBytes(l.rate)}/s · ↑{fmtBytes(l.totalUp)} ↓{fmtBytes(l.totalDown)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{busiest.length === 0 && <div style={styles.dim}>no links in range</div>}
|
||||
</Section>
|
||||
<Section title="Legend">
|
||||
<div style={styles.dim}>blue line — pose broadcast (5 Hz, ~46 B)</div>
|
||||
<div style={styles.dim}>green line — bulk sync (sealed partitions)</div>
|
||||
<div style={styles.dim}>flash — broadcast event delivered</div>
|
||||
<div style={styles.dim}>red circle — mobile obstacle</div>
|
||||
</Section>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function sx(x: number): number {
|
||||
return (x / AREA) * VIEW;
|
||||
}
|
||||
function sy(y: number): number {
|
||||
return (y / AREA) * VIEW;
|
||||
}
|
||||
|
||||
function gridLines(): JSX.Element {
|
||||
const lines = [];
|
||||
for (let i = 1; i < 10; i++) {
|
||||
const p = (i / 10) * VIEW;
|
||||
lines.push(
|
||||
<line key={`v${i}`} x1={p} y1={0} x2={p} y2={VIEW} stroke="#1a2436" strokeWidth={1} />,
|
||||
<line key={`h${i}`} x1={0} y1={p} x2={VIEW} y2={p} stroke="#1a2436" strokeWidth={1} />,
|
||||
);
|
||||
}
|
||||
return <g>{lines}</g>;
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }): JSX.Element {
|
||||
return (
|
||||
<div style={styles.section}>
|
||||
<div style={styles.sectionTitle}>{title}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ name, value }: { name: string; value: string }): JSX.Element {
|
||||
return (
|
||||
<div style={styles.statRow}>
|
||||
<span style={styles.dim}>{name}</span>
|
||||
<span style={styles.statValue}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
shell: {
|
||||
minHeight: "100vh",
|
||||
background: "#070b12",
|
||||
color: "#dde4f0",
|
||||
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
},
|
||||
header: {
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: "14px 22px",
|
||||
borderBottom: "1px solid #1a2436",
|
||||
flexWrap: "wrap",
|
||||
gap: 12,
|
||||
},
|
||||
title: { fontSize: 18, fontWeight: 700, letterSpacing: 4, color: "#8fb4e8" },
|
||||
subtitle: { fontSize: 11, color: "#5a6a85", marginTop: 2 },
|
||||
controls: { display: "flex", gap: 18, alignItems: "center", flexWrap: "wrap" },
|
||||
label: { display: "flex", gap: 8, alignItems: "center", fontSize: 12, color: "#8b98b0" },
|
||||
value: { minWidth: 30, color: "#dde4f0" },
|
||||
button: {
|
||||
background: "#16233a",
|
||||
color: "#8fb4e8",
|
||||
border: "1px solid #2a3c5c",
|
||||
borderRadius: 6,
|
||||
padding: "6px 16px",
|
||||
fontFamily: "inherit",
|
||||
fontSize: 12,
|
||||
cursor: "pointer",
|
||||
},
|
||||
main: { display: "flex", flex: 1, gap: 0, minHeight: 0 },
|
||||
canvas: { flex: 1, minWidth: 0, display: "block" },
|
||||
panel: {
|
||||
width: 320,
|
||||
borderLeft: "1px solid #1a2436",
|
||||
padding: 16,
|
||||
overflowY: "auto",
|
||||
background: "#0a0f18",
|
||||
},
|
||||
section: { marginBottom: 22 },
|
||||
sectionTitle: {
|
||||
fontSize: 11,
|
||||
letterSpacing: 2,
|
||||
color: "#5a6a85",
|
||||
textTransform: "uppercase",
|
||||
marginBottom: 10,
|
||||
borderBottom: "1px solid #1a2436",
|
||||
paddingBottom: 5,
|
||||
},
|
||||
statRow: { display: "flex", justifyContent: "space-between", padding: "3px 0", fontSize: 12 },
|
||||
statValue: { color: "#8fb4e8" },
|
||||
linkRow: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
padding: "4px 0",
|
||||
fontSize: 11,
|
||||
borderBottom: "1px dotted #141d2e",
|
||||
},
|
||||
linkName: { color: "#c8d2e4" },
|
||||
linkStat: { color: "#5a8c6e", fontSize: 10, marginTop: 1 },
|
||||
dim: { color: "#5a6a85", fontSize: 11, padding: "2px 0" },
|
||||
linkLabel: { fill: "#39d98a", fontSize: 10, textAnchor: "middle", fontFamily: "inherit" },
|
||||
droneLabel: { fill: "#6a7a95", fontSize: 9, textAnchor: "middle", fontFamily: "inherit" },
|
||||
obLabel: { fill: "#c05a6a", fontSize: 9, textAnchor: "middle", fontFamily: "inherit" },
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing #root");
|
||||
|
||||
document.body.style.margin = "0";
|
||||
createRoot(rootEl).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,236 @@
|
||||
// Swarm simulation core: pure functions over immutable-ish state.
|
||||
// Models patrol flight, separation, obstacle avoidance, mesh links with
|
||||
// pose-broadcast flashes and opportunistic bulk sync transfers.
|
||||
|
||||
export interface Vec {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface Drone {
|
||||
id: number;
|
||||
pos: Vec;
|
||||
vel: Vec;
|
||||
heading: number; // radians
|
||||
waypoint: number;
|
||||
battery: number; // 0..100
|
||||
channel: number; // Wi-Fi channel currently in use
|
||||
}
|
||||
|
||||
export interface Obstacle {
|
||||
id: number;
|
||||
pos: Vec;
|
||||
radius: number;
|
||||
moving: boolean;
|
||||
vel: Vec;
|
||||
}
|
||||
|
||||
export interface LinkStats {
|
||||
key: string;
|
||||
a: number;
|
||||
b: number;
|
||||
totalUp: number; // bytes, a -> b
|
||||
totalDown: number; // bytes, b -> a
|
||||
rate: number; // current bytes/s (both directions)
|
||||
flash: number; // 0..1, decaying broadcast pulse
|
||||
bulk: number; // remaining bytes of an in-flight bulk transfer
|
||||
}
|
||||
|
||||
export interface World {
|
||||
t: number;
|
||||
drones: Drone[];
|
||||
obstacles: Obstacle[];
|
||||
links: Map<string, LinkStats>;
|
||||
totalBytes: number;
|
||||
broadcasts: number;
|
||||
}
|
||||
|
||||
export const AREA = 1000; // meters, square side
|
||||
export const LINK_RANGE = 320;
|
||||
const CRUISE = 28;
|
||||
const SEPARATION = 55;
|
||||
const POSE_BYTES = 46;
|
||||
const POSE_HZ = 5;
|
||||
const CHANNELS = [1, 6, 11, 36, 40, 44, 149, 157];
|
||||
|
||||
let seedState = 1234;
|
||||
export function seed(v: number): void {
|
||||
seedState = v || 1;
|
||||
}
|
||||
function rnd(): number {
|
||||
// xorshift32 — deterministic runs
|
||||
seedState ^= seedState << 13;
|
||||
seedState ^= seedState >>> 17;
|
||||
seedState ^= seedState << 5;
|
||||
return ((seedState >>> 0) % 100000) / 100000;
|
||||
}
|
||||
|
||||
const WAYPOINTS: Vec[] = [
|
||||
{ x: 120, y: 120 },
|
||||
{ x: AREA - 120, y: 150 },
|
||||
{ x: AREA - 150, y: AREA - 120 },
|
||||
{ x: 150, y: AREA - 150 },
|
||||
];
|
||||
|
||||
export function makeWorld(droneCount: number): World {
|
||||
const drones: Drone[] = Array.from({ length: droneCount }, (_, i) => {
|
||||
const angle = (i / droneCount) * Math.PI * 2;
|
||||
return {
|
||||
id: i,
|
||||
pos: {
|
||||
x: AREA / 2 + Math.cos(angle) * (150 + rnd() * 120),
|
||||
y: AREA / 2 + Math.sin(angle) * (150 + rnd() * 120),
|
||||
},
|
||||
vel: { x: 0, y: 0 },
|
||||
heading: angle,
|
||||
waypoint: i % WAYPOINTS.length,
|
||||
battery: 90 + rnd() * 10,
|
||||
channel: CHANNELS[i % CHANNELS.length],
|
||||
};
|
||||
});
|
||||
const obstacles: Obstacle[] = [
|
||||
{ id: 0, pos: { x: 340, y: 420 }, radius: 60, moving: false, vel: { x: 0, y: 0 } },
|
||||
{ id: 1, pos: { x: 700, y: 260 }, radius: 45, moving: false, vel: { x: 0, y: 0 } },
|
||||
{ id: 2, pos: { x: 560, y: 720 }, radius: 70, moving: false, vel: { x: 0, y: 0 } },
|
||||
{ id: 3, pos: { x: 200, y: 800 }, radius: 30, moving: true, vel: { x: 14, y: -6 } },
|
||||
{ id: 4, pos: { x: 820, y: 620 }, radius: 26, moving: true, vel: { x: -10, y: 9 } },
|
||||
];
|
||||
return { t: 0, drones, obstacles, links: new Map(), totalBytes: 0, broadcasts: 0 };
|
||||
}
|
||||
|
||||
function linkKey(a: number, b: number): string {
|
||||
return a < b ? `${a}-${b}` : `${b}-${a}`;
|
||||
}
|
||||
|
||||
function steer(d: Drone, world: World, dt: number): Drone {
|
||||
const wp = WAYPOINTS[(d.waypoint + d.id) % WAYPOINTS.length];
|
||||
let ax = wp.x - d.pos.x;
|
||||
let ay = wp.y - d.pos.y;
|
||||
const wpDist = Math.hypot(ax, ay);
|
||||
let waypoint = d.waypoint;
|
||||
if (wpDist < 90) waypoint = (d.waypoint + 1) % WAYPOINTS.length;
|
||||
ax /= wpDist || 1;
|
||||
ay /= wpDist || 1;
|
||||
|
||||
// Separation from peers
|
||||
for (const other of world.drones) {
|
||||
if (other.id === d.id) continue;
|
||||
const dx = d.pos.x - other.pos.x;
|
||||
const dy = d.pos.y - other.pos.y;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
if (dist < SEPARATION && dist > 0.01) {
|
||||
const push = (SEPARATION - dist) / SEPARATION;
|
||||
ax += (dx / dist) * push * 2.4;
|
||||
ay += (dy / dist) * push * 2.4;
|
||||
}
|
||||
}
|
||||
// Obstacle avoidance
|
||||
for (const ob of world.obstacles) {
|
||||
const dx = d.pos.x - ob.pos.x;
|
||||
const dy = d.pos.y - ob.pos.y;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
const margin = ob.radius + 55;
|
||||
if (dist < margin && dist > 0.01) {
|
||||
const push = (margin - dist) / margin;
|
||||
ax += (dx / dist) * push * 4.0;
|
||||
ay += (dy / dist) * push * 4.0;
|
||||
}
|
||||
}
|
||||
|
||||
const mag = Math.hypot(ax, ay) || 1;
|
||||
const vx = d.vel.x * 0.85 + (ax / mag) * CRUISE * 0.15;
|
||||
const vy = d.vel.y * 0.85 + (ay / mag) * CRUISE * 0.15;
|
||||
const heading = Math.atan2(vy, vx);
|
||||
return {
|
||||
...d,
|
||||
waypoint,
|
||||
vel: { x: vx, y: vy },
|
||||
heading,
|
||||
pos: {
|
||||
x: Math.max(20, Math.min(AREA - 20, d.pos.x + vx * dt)),
|
||||
y: Math.max(20, Math.min(AREA - 20, d.pos.y + vy * dt)),
|
||||
},
|
||||
battery: Math.max(0, d.battery - dt * 0.05),
|
||||
channel: rnd() < dt * 0.15 ? CHANNELS[Math.floor(rnd() * CHANNELS.length)] : d.channel,
|
||||
};
|
||||
}
|
||||
|
||||
export function tick(world: World, dt: number): World {
|
||||
const drones = world.drones.map((d) => steer(d, world, dt));
|
||||
const obstacles = world.obstacles.map((ob) =>
|
||||
ob.moving
|
||||
? {
|
||||
...ob,
|
||||
pos: { x: ob.pos.x + ob.vel.x * dt, y: ob.pos.y + ob.vel.y * dt },
|
||||
vel: {
|
||||
x: ob.pos.x < 60 || ob.pos.x > AREA - 60 ? -ob.vel.x : ob.vel.x,
|
||||
y: ob.pos.y < 60 || ob.pos.y > AREA - 60 ? -ob.vel.y : ob.vel.y,
|
||||
},
|
||||
}
|
||||
: ob,
|
||||
);
|
||||
|
||||
const links = new Map(world.links);
|
||||
let totalBytes = world.totalBytes;
|
||||
let broadcasts = world.broadcasts;
|
||||
|
||||
// Decay all links; drop the ones out of range
|
||||
for (const [key, l] of links) {
|
||||
const a = drones[l.a];
|
||||
const b = drones[l.b];
|
||||
const inRange =
|
||||
a && b && Math.hypot(a.pos.x - b.pos.x, a.pos.y - b.pos.y) <= LINK_RANGE;
|
||||
if (!inRange) {
|
||||
links.delete(key);
|
||||
continue;
|
||||
}
|
||||
links.set(key, { ...l, flash: Math.max(0, l.flash - dt * 3), rate: l.rate * 0.9 });
|
||||
}
|
||||
|
||||
for (let i = 0; i < drones.length; i++) {
|
||||
for (let j = i + 1; j < drones.length; j++) {
|
||||
const dist = Math.hypot(
|
||||
drones[i].pos.x - drones[j].pos.x,
|
||||
drones[i].pos.y - drones[j].pos.y,
|
||||
);
|
||||
if (dist > LINK_RANGE) continue;
|
||||
const key = linkKey(i, j);
|
||||
const link =
|
||||
links.get(key) ??
|
||||
({ key, a: i, b: j, totalUp: 0, totalDown: 0, rate: 0, flash: 0, bulk: 0 } as LinkStats);
|
||||
|
||||
// Pose broadcasts: 5 Hz each direction
|
||||
const poseBytes = POSE_BYTES * POSE_HZ * dt;
|
||||
let up = poseBytes;
|
||||
let down = poseBytes;
|
||||
if (rnd() < dt * POSE_HZ * 0.35) {
|
||||
link.flash = 1;
|
||||
broadcasts += 1;
|
||||
}
|
||||
// Occasional bulk sync (sealed partition replication)
|
||||
if (link.bulk <= 0 && rnd() < dt * 0.02) {
|
||||
link.bulk = 50_000 + rnd() * 450_000;
|
||||
}
|
||||
if (link.bulk > 0) {
|
||||
const chunk = Math.min(link.bulk, 120_000 * dt);
|
||||
link.bulk -= chunk;
|
||||
if (rnd() < 0.5) up += chunk;
|
||||
else down += chunk;
|
||||
}
|
||||
link.totalUp += up;
|
||||
link.totalDown += down;
|
||||
link.rate = link.rate * 0.9 + ((up + down) / dt) * 0.1;
|
||||
totalBytes += up + down;
|
||||
links.set(key, { ...link });
|
||||
}
|
||||
}
|
||||
|
||||
return { t: world.t + dt, drones, obstacles, links, totalBytes, broadcasts };
|
||||
}
|
||||
|
||||
export function fmtBytes(n: number): string {
|
||||
if (n < 1024) return `${n.toFixed(0)} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
Reference in New Issue
Block a user