36f0ea66b3
- src/lib/vtt/uvtt.ts: pure, tested parser converting UVTT grid-unit geometry to image pixels (pixels_per_grid → gridSize), extracting the base64 image, walls (line_of_sight), doors (portals) and lights; plus toUvtt() for export. - BattleMap gains walls/doors/lights (Dexie v11, backfilled). mapsRepo.createFromVtt. - MapsPage "+ Add map" now accepts .dd2vtt/.uvtt/.df2vtt (text) and images, and a per-map "Export .uvtt" (image dims → map_size). downloadText() helper. - Walls render faintly for the GM (CanvasView.walls / drawScene) so imported line-of-sight is visible; Phase 4 will turn walls into dynamic vision. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
454 lines
19 KiB
TypeScript
454 lines
19 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
||
import type { Point, Viewport } from '@/lib/map';
|
||
import { fitViewport, zoomToPoint } from '@/lib/map';
|
||
import { cn } from '@/lib/cn';
|
||
|
||
/** Render-only drawing shape (both MapDrawing and PlayerDrawing satisfy this). */
|
||
export interface CanvasDrawing {
|
||
id: string;
|
||
kind: 'freehand' | 'line' | 'rect' | 'circle' | 'arrow' | 'text';
|
||
points: Point[];
|
||
color: string;
|
||
width: number;
|
||
text?: string | undefined;
|
||
}
|
||
|
||
/** Minimal token shape both the editor (MapToken) and player view (PlayerToken) satisfy. */
|
||
export interface CanvasToken {
|
||
id: string;
|
||
label: string;
|
||
color: string;
|
||
col: number;
|
||
row: number;
|
||
size: number;
|
||
kind: 'pc' | 'npc' | 'monster' | 'object';
|
||
/** resolved icon data URL (token image or linked character portrait) */
|
||
image?: string | undefined;
|
||
hp?: { current: number; max: number; temp: number } | undefined;
|
||
conditions: { name: string; value?: number | undefined }[];
|
||
}
|
||
|
||
export interface CanvasView {
|
||
image?: string | undefined;
|
||
gridSize: number;
|
||
showGrid: boolean;
|
||
fogEnabled: boolean;
|
||
revealed: string[];
|
||
tokens: CanvasToken[];
|
||
drawings: CanvasDrawing[];
|
||
/** sight-blocking walls, drawn faintly for the GM (not sent to players) */
|
||
walls?: { points: Point[] }[] | undefined;
|
||
}
|
||
|
||
export interface Overlay {
|
||
cells?: string[];
|
||
line?: { a: Point; b: Point } | undefined;
|
||
label?: { at: Point; text: string } | undefined;
|
||
draft?: CanvasDrawing | undefined;
|
||
/** in-progress polygon: committed vertices + a rubber-band point at the cursor */
|
||
poly?: { points: Point[]; cursor?: Point | undefined } | undefined;
|
||
/** brush footprint at the cursor (radius in cells) */
|
||
brush?: { col: number; row: number; size: number } | undefined;
|
||
}
|
||
|
||
type Phase = 'down' | 'move' | 'up' | 'hover';
|
||
|
||
interface Props {
|
||
view: CanvasView;
|
||
/** CSS height of the clip viewport (e.g. 'calc(100vh - 14rem)') */
|
||
viewportHeight?: string;
|
||
readOnly?: boolean;
|
||
/** dim unrevealed cells fully (player view) vs. translucent (GM editor) */
|
||
playerFog?: boolean;
|
||
overlay?: Overlay;
|
||
onPointer?: (phase: Phase, p: { point: Point; col: number; row: number }) => void;
|
||
onLeave?: () => void;
|
||
onTokenMove?: (id: string, col: number, row: number) => void;
|
||
onTokenClick?: (id: string) => void;
|
||
tokensDraggable?: boolean;
|
||
onReady?: (info: { cols: number; rows: number }) => void;
|
||
}
|
||
|
||
const MAX_DPR = 2.5;
|
||
|
||
/**
|
||
* Shared map renderer. A single canvas draws the image + grid + fog + drawings +
|
||
* overlay with a camera transform (translate+scale) at the device's pixel
|
||
* resolution, so everything stays crisp at any zoom (unlike CSS-scaling a
|
||
* natural-resolution bitmap). Tokens are DOM, in a matching transformed layer.
|
||
*/
|
||
export function MapCanvas({ view, viewportHeight = '70vh', readOnly, playerFog, overlay, onPointer, onLeave, onTokenMove, onTokenClick, tokensDraggable, onReady }: Props) {
|
||
const [natural, setNatural] = useState<{ w: number; h: number } | null>(null);
|
||
const [vp, setVp] = useState<Viewport>({ zoom: 1, panX: 0, panY: 0 });
|
||
const [size, setSize] = useState({ w: 0, h: 0 });
|
||
const outerRef = useRef<HTMLDivElement>(null);
|
||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||
const imgRef = useRef<HTMLImageElement | null>(null);
|
||
const fittedRef = useRef<string | undefined>(undefined);
|
||
|
||
const gridSize = view.gridSize;
|
||
const W = natural?.w ?? 0;
|
||
const H = natural?.h ?? 0;
|
||
const cols = natural ? Math.ceil(W / gridSize) : 0;
|
||
const rows = natural ? Math.ceil(H / gridSize) : 0;
|
||
|
||
// Load the image bitmap (used both for sizing and for canvas drawImage).
|
||
useEffect(() => {
|
||
if (!view.image) { setNatural(null); imgRef.current = null; return; }
|
||
const img = new Image();
|
||
img.onload = () => { imgRef.current = img; setNatural({ w: img.naturalWidth, h: img.naturalHeight }); };
|
||
img.src = view.image;
|
||
}, [view.image]);
|
||
|
||
useEffect(() => { if (natural) onReady?.({ cols, rows }); }, [cols, rows, natural, onReady]);
|
||
|
||
// Track the viewport's CSS size.
|
||
useEffect(() => {
|
||
const el = outerRef.current;
|
||
if (!el) return;
|
||
const update = () => setSize({ w: el.clientWidth, h: el.clientHeight });
|
||
update();
|
||
const ro = new ResizeObserver(update);
|
||
ro.observe(el);
|
||
return () => ro.disconnect();
|
||
}, []);
|
||
|
||
// Fit once per image, as soon as both the image and viewport size are known.
|
||
useEffect(() => {
|
||
if (!natural || size.w === 0 || fittedRef.current === view.image) return;
|
||
fittedRef.current = view.image;
|
||
setVp(fitViewport(size.w, size.h, natural.w, natural.h));
|
||
}, [natural, size, view.image]);
|
||
|
||
// Wheel zoom (native + non-passive so the page doesn't scroll).
|
||
useEffect(() => {
|
||
const el = outerRef.current;
|
||
if (!el) return;
|
||
const onWheel = (e: WheelEvent) => {
|
||
e.preventDefault();
|
||
const r = el.getBoundingClientRect();
|
||
setVp((v) => zoomToPoint(v, Math.exp(-e.deltaY * 0.0015), e.clientX - r.left, e.clientY - r.top));
|
||
};
|
||
el.addEventListener('wheel', onWheel, { passive: false });
|
||
return () => el.removeEventListener('wheel', onWheel);
|
||
}, []);
|
||
|
||
// Redraw whenever camera/content/size changes (rAF-coalesced).
|
||
useEffect(() => {
|
||
const raf = requestAnimationFrame(() => {
|
||
drawScene(canvasRef.current, imgRef.current, size.w, size.h, {
|
||
vp, W, H, gridSize, cols, rows,
|
||
showGrid: view.showGrid, fogEnabled: view.fogEnabled, revealed: view.revealed,
|
||
playerFog: !!playerFog, drawings: view.drawings, overlay, walls: view.walls,
|
||
});
|
||
});
|
||
return () => cancelAnimationFrame(raf);
|
||
});
|
||
|
||
const toPoint = (e: React.PointerEvent): { point: Point; col: number; row: number } => {
|
||
const r = outerRef.current!.getBoundingClientRect();
|
||
const x = (e.clientX - r.left - vp.panX) / vp.zoom;
|
||
const y = (e.clientY - r.top - vp.panY) / vp.zoom;
|
||
return { point: { x, y }, col: Math.floor(x / gridSize), row: Math.floor(y / gridSize) };
|
||
};
|
||
|
||
const pan = useRef<{ x: number; y: number; px: number; py: number } | null>(null);
|
||
const interactive = !readOnly && !!onPointer;
|
||
|
||
const ixDown = (e: React.PointerEvent) => {
|
||
const wantPan = e.button === 1 || e.button === 2 || !interactive;
|
||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||
if (wantPan) { pan.current = { x: e.clientX, y: e.clientY, px: vp.panX, py: vp.panY }; e.preventDefault(); return; }
|
||
onPointer!('down', toPoint(e));
|
||
};
|
||
const ixMove = (e: React.PointerEvent) => {
|
||
if (pan.current) {
|
||
const { x, y, px, py } = pan.current;
|
||
setVp((v) => ({ ...v, panX: px + (e.clientX - x), panY: py + (e.clientY - y) }));
|
||
return;
|
||
}
|
||
if (interactive) onPointer!(e.buttons ? 'move' : 'hover', toPoint(e));
|
||
};
|
||
const ixUp = (e: React.PointerEvent) => {
|
||
if (pan.current) { pan.current = null; return; }
|
||
if (interactive) onPointer!('up', toPoint(e));
|
||
};
|
||
|
||
const zoomCentered = (factor: number) => setVp((v) => zoomToPoint(v, factor, size.w / 2, size.h / 2));
|
||
const fit = () => { if (natural) setVp(fitViewport(size.w, size.h, natural.w, natural.h)); };
|
||
|
||
// NOTE: always render the outer container (even with no image yet) so the
|
||
// ResizeObserver attaches. In the networked player view the map image arrives
|
||
// late over WebSocket; if we early-returned, the observer never bound, `size`
|
||
// stayed 0, fit() never ran, and the map showed at 100% instead of fit.
|
||
return (
|
||
<div ref={outerRef} data-testid="map-viewport" data-zoom={vp.zoom} className="relative overflow-hidden rounded-lg border border-line bg-surface" style={{ height: viewportHeight, touchAction: 'none' }}>
|
||
{!view.image
|
||
? <p className="p-3 text-sm text-muted">No image.</p>
|
||
: !natural
|
||
? <p className="p-3 text-sm text-muted">Loading map…</p>
|
||
: null}
|
||
|
||
<canvas ref={canvasRef} className="pointer-events-none absolute inset-0 h-full w-full" />
|
||
|
||
{/* pointer surface (below tokens so token drags win; tokens are small) */}
|
||
{view.image && (
|
||
<div
|
||
className="absolute inset-0"
|
||
style={{ cursor: interactive ? 'crosshair' : 'grab', touchAction: 'none' }}
|
||
onPointerDown={ixDown}
|
||
onPointerMove={ixMove}
|
||
onPointerUp={ixUp}
|
||
onPointerLeave={() => { if (interactive) onLeave?.(); }}
|
||
onContextMenu={(e) => e.preventDefault()}
|
||
/>
|
||
)}
|
||
|
||
{/* tokens: DOM positioned in screen space (no CSS scale → labels stay crisp) */}
|
||
{natural && (
|
||
<div className="pointer-events-none absolute inset-0">
|
||
{view.tokens.map((t) => (
|
||
<TokenChip key={t.id} token={t} gridSize={gridSize} vp={vp} cols={cols} rows={rows}
|
||
draggable={!!tokensDraggable} onMove={onTokenMove} onClick={onTokenClick} />
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{natural && (
|
||
<div className="absolute bottom-2 right-2 flex items-center gap-1 rounded-md border border-line bg-panel/90 px-1 py-0.5 text-xs shadow backdrop-blur">
|
||
<button className="grid h-6 w-6 place-items-center rounded hover:bg-elevated" onClick={() => zoomCentered(1 / 1.25)} aria-label="Zoom out">−</button>
|
||
<span className="w-10 text-center tabular-nums text-muted">{Math.round(vp.zoom * 100)}%</span>
|
||
<button className="grid h-6 w-6 place-items-center rounded hover:bg-elevated" onClick={() => zoomCentered(1.25)} aria-label="Zoom in">+</button>
|
||
<button className="grid h-6 place-items-center rounded px-1.5 hover:bg-elevated" onClick={fit} aria-label="Fit map to view" title="Fit">⊡</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface SceneParams {
|
||
vp: Viewport;
|
||
W: number; H: number; gridSize: number; cols: number; rows: number;
|
||
showGrid: boolean; fogEnabled: boolean; revealed: string[]; playerFog: boolean;
|
||
drawings: CanvasDrawing[]; overlay?: Overlay | undefined; walls?: { points: Point[] }[] | undefined;
|
||
}
|
||
|
||
/** Draw the whole scene at device resolution with a camera transform. */
|
||
function drawScene(canvas: HTMLCanvasElement | null, img: HTMLImageElement | null, vw: number, vh: number, p: SceneParams): void {
|
||
if (!canvas || vw === 0 || vh === 0) return;
|
||
const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
|
||
const bw = Math.round(vw * dpr), bh = Math.round(vh * dpr);
|
||
if (canvas.width !== bw || canvas.height !== bh) { canvas.width = bw; canvas.height = bh; }
|
||
const ctx = canvas.getContext('2d');
|
||
if (!ctx) return;
|
||
const { zoom, panX, panY } = p.vp;
|
||
// Camera maps world px → CSS px → device px. Device transform = dpr only.
|
||
const cam = () => ctx.setTransform(zoom * dpr, 0, 0, zoom * dpr, panX * dpr, panY * dpr);
|
||
const dev = () => ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
|
||
dev();
|
||
ctx.clearRect(0, 0, vw, vh);
|
||
if (!img) return;
|
||
|
||
// Map image
|
||
cam();
|
||
ctx.imageSmoothingEnabled = true;
|
||
ctx.imageSmoothingQuality = 'high';
|
||
ctx.drawImage(img, 0, 0, p.W, p.H);
|
||
|
||
// Fog (world space)
|
||
if (p.fogEnabled) {
|
||
const revealed = new Set(p.revealed);
|
||
ctx.fillStyle = p.playerFog ? 'rgba(8,8,12,1)' : 'rgba(0,0,0,0.55)';
|
||
for (let c = 0; c < p.cols; c++) for (let r = 0; r < p.rows; r++) {
|
||
if (!revealed.has(`${c},${r}`)) ctx.fillRect(c * p.gridSize, r * p.gridSize, p.gridSize + 0.5, p.gridSize + 0.5);
|
||
}
|
||
}
|
||
|
||
// Grid (device space → crisp 1px lines)
|
||
if (p.showGrid) {
|
||
const step = p.gridSize * zoom;
|
||
if (step >= 4) {
|
||
dev();
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.28)';
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
const x0 = Math.max(0, panX), x1 = Math.min(vw, panX + p.W * zoom);
|
||
const y0 = Math.max(0, panY), y1 = Math.min(vh, panY + p.H * zoom);
|
||
for (let c = Math.max(0, Math.floor((-panX) / step)); c <= p.cols; c++) {
|
||
const x = Math.round(panX + c * step) + 0.5;
|
||
if (x < x0 - 1 || x > x1 + 1) continue;
|
||
ctx.moveTo(x, y0); ctx.lineTo(x, y1);
|
||
}
|
||
for (let r = Math.max(0, Math.floor((-panY) / step)); r <= p.rows; r++) {
|
||
const y = Math.round(panY + r * step) + 0.5;
|
||
if (y < y0 - 1 || y > y1 + 1) continue;
|
||
ctx.moveTo(x0, y); ctx.lineTo(x1, y);
|
||
}
|
||
ctx.stroke();
|
||
}
|
||
}
|
||
|
||
// Drawings (world space; crisp under the camera transform)
|
||
cam();
|
||
for (const d of p.drawings) drawShape(ctx, d);
|
||
|
||
// Walls (GM only — faint, so imported line-of-sight is visible while editing)
|
||
if (p.walls?.length) {
|
||
cam();
|
||
ctx.strokeStyle = 'rgba(80,200,255,0.6)';
|
||
ctx.lineWidth = 2 / zoom;
|
||
ctx.lineJoin = 'round'; ctx.lineCap = 'round';
|
||
for (const w of p.walls) {
|
||
if (w.points.length < 2) continue;
|
||
ctx.beginPath();
|
||
ctx.moveTo(w.points[0]!.x, w.points[0]!.y);
|
||
for (const pt of w.points.slice(1)) ctx.lineTo(pt.x, pt.y);
|
||
ctx.stroke();
|
||
}
|
||
}
|
||
|
||
// Overlay (AoE/measure/draft/poly/brush)
|
||
const o = p.overlay;
|
||
if (o) {
|
||
cam();
|
||
if (o.cells?.length) {
|
||
ctx.fillStyle = 'rgba(212,175,55,0.35)';
|
||
for (const k of o.cells) { const [c, r] = k.split(',').map(Number); ctx.fillRect(c! * p.gridSize, r! * p.gridSize, p.gridSize, p.gridSize); }
|
||
}
|
||
if (o.draft) drawShape(ctx, o.draft);
|
||
if (o.brush) {
|
||
ctx.strokeStyle = 'rgba(212,175,55,0.95)';
|
||
ctx.lineWidth = 2 / zoom;
|
||
const s = o.brush.size;
|
||
ctx.strokeRect((o.brush.col - s) * p.gridSize, (o.brush.row - s) * p.gridSize, (s * 2 + 1) * p.gridSize, (s * 2 + 1) * p.gridSize);
|
||
}
|
||
if (o.poly && o.poly.points.length) {
|
||
const pts = o.poly.points;
|
||
ctx.strokeStyle = '#d4af37';
|
||
ctx.lineWidth = 2 / zoom;
|
||
ctx.beginPath();
|
||
ctx.moveTo(pts[0]!.x, pts[0]!.y);
|
||
for (const pt of pts.slice(1)) ctx.lineTo(pt.x, pt.y);
|
||
if (o.poly.cursor) ctx.lineTo(o.poly.cursor.x, o.poly.cursor.y);
|
||
if (pts.length >= 2) ctx.closePath();
|
||
ctx.stroke();
|
||
ctx.fillStyle = '#fff';
|
||
for (const pt of pts) { ctx.beginPath(); ctx.arc(pt.x, pt.y, 4 / zoom, 0, Math.PI * 2); ctx.fill(); }
|
||
}
|
||
if (o.line) {
|
||
ctx.strokeStyle = '#d4af37'; ctx.lineWidth = 2 / zoom;
|
||
ctx.beginPath(); ctx.moveTo(o.line.a.x, o.line.a.y); ctx.lineTo(o.line.b.x, o.line.b.y); ctx.stroke();
|
||
}
|
||
if (o.label) {
|
||
// label in screen space → fixed, readable size at any zoom
|
||
dev();
|
||
ctx.font = '600 13px sans-serif';
|
||
ctx.fillStyle = '#fff'; ctx.strokeStyle = 'rgba(0,0,0,0.75)'; ctx.lineWidth = 3;
|
||
const x = panX + o.label.at.x * zoom + 8, y = panY + o.label.at.y * zoom - 8;
|
||
ctx.strokeText(o.label.text, x, y); ctx.fillText(o.label.text, x, y);
|
||
}
|
||
}
|
||
dev();
|
||
}
|
||
|
||
/** Draw a vector annotation in world coordinates (crisp under the camera). */
|
||
function drawShape(ctx: CanvasRenderingContext2D, d: CanvasDrawing): void {
|
||
ctx.strokeStyle = d.color; ctx.fillStyle = d.color; ctx.lineWidth = d.width; ctx.lineJoin = 'round'; ctx.lineCap = 'round';
|
||
const p = d.points;
|
||
if (d.kind === 'text' && d.text && p[0]) {
|
||
ctx.font = `${Math.max(12, d.width * 5)}px sans-serif`;
|
||
ctx.fillText(d.text, p[0].x, p[0].y);
|
||
return;
|
||
}
|
||
if (p.length < 2) return;
|
||
const a = p[0]!, b = p[p.length - 1]!;
|
||
ctx.beginPath();
|
||
if (d.kind === 'rect') {
|
||
ctx.rect(Math.min(a.x, b.x), Math.min(a.y, b.y), Math.abs(b.x - a.x), Math.abs(b.y - a.y));
|
||
} else if (d.kind === 'circle') {
|
||
ctx.arc(a.x, a.y, Math.hypot(b.x - a.x, b.y - a.y), 0, Math.PI * 2);
|
||
} else {
|
||
ctx.moveTo(p[0]!.x, p[0]!.y);
|
||
for (const pt of p.slice(1)) ctx.lineTo(pt.x, pt.y);
|
||
if (d.kind === 'arrow') {
|
||
const ang = Math.atan2(b.y - p[p.length - 2]!.y, b.x - p[p.length - 2]!.x), h = 10 + d.width;
|
||
ctx.moveTo(b.x, b.y); ctx.lineTo(b.x - h * Math.cos(ang - 0.4), b.y - h * Math.sin(ang - 0.4));
|
||
ctx.moveTo(b.x, b.y); ctx.lineTo(b.x - h * Math.cos(ang + 0.4), b.y - h * Math.sin(ang + 0.4));
|
||
}
|
||
}
|
||
ctx.stroke();
|
||
}
|
||
|
||
function TokenChip({ token, gridSize, vp, cols, rows, draggable, onMove, onClick }: {
|
||
token: CanvasToken; gridSize: number; vp: Viewport; cols: number; rows: number;
|
||
draggable: boolean; onMove?: ((id: string, col: number, row: number) => void) | undefined; onClick?: ((id: string) => void) | undefined;
|
||
}) {
|
||
const ref = useRef<HTMLDivElement>(null);
|
||
const drag = useRef<{ x: number; y: number; col: number; row: number; moved: boolean } | null>(null);
|
||
const [drift, setDrift] = useState({ dx: 0, dy: 0 });
|
||
const { zoom, panX, panY } = vp;
|
||
const span = gridSize * token.size * zoom; // on-screen px
|
||
const box = span * 0.92;
|
||
const frac = token.hp && token.hp.max > 0 ? Math.max(0, Math.min(1, token.hp.current / token.hp.max)) : null;
|
||
|
||
const down = (e: React.PointerEvent) => {
|
||
if (draggable) { e.stopPropagation(); ref.current?.setPointerCapture(e.pointerId); drag.current = { x: e.clientX, y: e.clientY, col: token.col, row: token.row, moved: false }; }
|
||
};
|
||
const move = (e: React.PointerEvent) => {
|
||
if (!drag.current) return;
|
||
const dx = e.clientX - drag.current.x, dy = e.clientY - drag.current.y;
|
||
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) drag.current.moved = true;
|
||
setDrift({ dx, dy });
|
||
};
|
||
const up = (e: React.PointerEvent) => {
|
||
if (!drag.current) return;
|
||
e.stopPropagation();
|
||
ref.current?.releasePointerCapture(e.pointerId);
|
||
const moved = drag.current.moved;
|
||
if (moved && onMove) {
|
||
const col = Math.max(0, Math.min(cols - 1, drag.current.col + Math.round(drift.dx / (gridSize * zoom))));
|
||
const row = Math.max(0, Math.min(rows - 1, drag.current.row + Math.round(drift.dy / (gridSize * zoom))));
|
||
onMove(token.id, col, row);
|
||
} else if (!moved && onClick) onClick(token.id);
|
||
drag.current = null; setDrift({ dx: 0, dy: 0 });
|
||
};
|
||
|
||
return (
|
||
<div
|
||
ref={ref}
|
||
onPointerDown={down} onPointerMove={move} onPointerUp={up}
|
||
title={token.label}
|
||
data-testid="map-token"
|
||
className={cn('pointer-events-auto absolute grid place-items-center rounded-full border-2 border-black/50 font-bold leading-none text-black shadow', frac !== null && frac <= 0 && 'opacity-50')}
|
||
style={{
|
||
width: box, height: box,
|
||
left: panX + token.col * gridSize * zoom + span * 0.04 + drift.dx,
|
||
top: panY + token.row * gridSize * zoom + span * 0.04 + drift.dy,
|
||
fontSize: Math.max(8, Math.min(22, span * 0.3)),
|
||
background: token.color,
|
||
cursor: draggable ? 'grab' : onClick ? 'pointer' : 'default',
|
||
touchAction: 'none',
|
||
}}
|
||
>
|
||
{token.image && (
|
||
<img src={token.image} alt="" draggable={false}
|
||
className="pointer-events-none absolute inset-0 h-full w-full rounded-full object-cover" />
|
||
)}
|
||
{frac !== null && (
|
||
<svg className="pointer-events-none absolute inset-0" viewBox="0 0 36 36" aria-hidden>
|
||
<circle cx="18" cy="18" r="17" fill="none" stroke="rgba(0,0,0,0.35)" strokeWidth="2" />
|
||
<circle cx="18" cy="18" r="17" fill="none" stroke={frac > 0.5 ? '#66bb6a' : frac > 0 ? '#ffa726' : '#ef5350'}
|
||
strokeWidth="2.5" strokeDasharray={`${frac * 106.8} 106.8`} transform="rotate(-90 18 18)" strokeLinecap="round" />
|
||
</svg>
|
||
)}
|
||
{!token.image && <span className="z-10 truncate px-0.5">{token.label}</span>}
|
||
{token.conditions.length > 0 && (
|
||
<span className="absolute -right-1 -top-1 z-10 grid h-4 w-4 place-items-center rounded-full bg-danger text-[9px] text-white" title={token.conditions.map((c) => c.name).join(', ')}>
|
||
{token.conditions.length}
|
||
</span>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|