Initial commit
This commit is contained in:
291
apps/web/app/components/MediaPanel.tsx
Normal file
291
apps/web/app/components/MediaPanel.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
type Asset = {
|
||||
id: string;
|
||||
media_type: "image" | "video";
|
||||
mime_type: string;
|
||||
capture_ts_utc: string | null;
|
||||
thumb_small_key: string | null;
|
||||
thumb_med_key: string | null;
|
||||
poster_key: string | null;
|
||||
status: "new" | "processing" | "ready" | "failed";
|
||||
error_message: string | null;
|
||||
};
|
||||
|
||||
type AssetsResponse = {
|
||||
items: Asset[];
|
||||
};
|
||||
|
||||
type SignedUrlResponse = {
|
||||
url: string;
|
||||
expiresSeconds: number;
|
||||
};
|
||||
|
||||
type PreviewUrlState = Record<string, string | undefined>;
|
||||
|
||||
function startOfDayUtc(iso: string) {
|
||||
const d = new Date(iso);
|
||||
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 0, 0, 0));
|
||||
}
|
||||
|
||||
function endOfDayUtc(iso: string) {
|
||||
const start = startOfDayUtc(iso);
|
||||
return new Date(start.getTime() + 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
export function MediaPanel(props: { selectedDayIso: string | null }) {
|
||||
const [assets, setAssets] = useState<Asset[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [previews, setPreviews] = useState<PreviewUrlState>({});
|
||||
|
||||
const [viewer, setViewer] = useState<{
|
||||
asset: Asset;
|
||||
url: string;
|
||||
variant: "original" | "thumb_med" | "poster";
|
||||
} | null>(null);
|
||||
|
||||
const [viewerError, setViewerError] = useState<string | null>(null);
|
||||
const [videoFallback, setVideoFallback] = useState<{ posterUrl: string | null } | null>(null);
|
||||
|
||||
const range = useMemo(() => {
|
||||
if (!props.selectedDayIso) return null;
|
||||
const start = startOfDayUtc(props.selectedDayIso);
|
||||
const end = endOfDayUtc(props.selectedDayIso);
|
||||
return { start, end };
|
||||
}, [props.selectedDayIso]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
if (!range) {
|
||||
setAssets(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
setAssets(null);
|
||||
|
||||
const qs = new URLSearchParams({
|
||||
start: range.start.toISOString(),
|
||||
end: range.end.toISOString(),
|
||||
limit: "120",
|
||||
});
|
||||
|
||||
const res = await fetch(`/api/assets?${qs.toString()}`, { cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`assets_fetch_failed:${res.status}`);
|
||||
const json = (await res.json()) as AssetsResponse;
|
||||
|
||||
if (!cancelled) {
|
||||
setAssets(json.items);
|
||||
setPreviews({});
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
|
||||
void load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [range]);
|
||||
|
||||
async function loadSignedUrl(assetId: string, variant: "original" | "thumb_small" | "thumb_med" | "poster") {
|
||||
const res = await fetch(`/api/assets/${assetId}/url?variant=${variant}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) throw new Error(`presign_failed:${res.status}`);
|
||||
const json = (await res.json()) as SignedUrlResponse;
|
||||
return json.url;
|
||||
}
|
||||
|
||||
async function openViewer(asset: Asset) {
|
||||
if (asset.status === "failed") {
|
||||
setViewerError(`${asset.id}: ${asset.error_message ?? "asset_failed"}`);
|
||||
setViewer(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setViewerError(null);
|
||||
setVideoFallback(null);
|
||||
|
||||
const variant: "original" | "thumb_med" | "poster" = "original";
|
||||
const url = await loadSignedUrl(asset.id, variant);
|
||||
setViewer({ asset, url, variant });
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid", gap: 12 }}>
|
||||
<div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between" }}>
|
||||
<strong>Media</strong>
|
||||
<span style={{ color: "#666", fontSize: 12 }}>
|
||||
{props.selectedDayIso ? startOfDayUtc(props.selectedDayIso).toISOString().slice(0, 10) : "(select a day)"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error ? <div style={{ color: "#b00" }}>Error: {error}</div> : null}
|
||||
{!assets && props.selectedDayIso && !error ? (
|
||||
<div style={{ color: "#666" }}>Loading assets…</div>
|
||||
) : null}
|
||||
|
||||
{assets ? (
|
||||
<div style={{ display: "grid", gap: 8 }}>
|
||||
{assets.length === 0 ? <div style={{ color: "#666" }}>No assets.</div> : null}
|
||||
{assets.map((a) => (
|
||||
<button
|
||||
key={a.id}
|
||||
type="button"
|
||||
onClick={() => void openViewer(a)}
|
||||
onPointerEnter={() => {
|
||||
if (previews[a.id] !== undefined) return;
|
||||
|
||||
const variant = a.media_type === "image" ? "thumb_small" : "poster";
|
||||
const promise = loadSignedUrl(a.id, variant).catch(() => undefined);
|
||||
|
||||
void promise.then((url) => {
|
||||
setPreviews((prev) => ({ ...prev, [a.id]: url }));
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
textAlign: "left",
|
||||
padding: 10,
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: 8,
|
||||
background: "white",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "72px 1fr", gap: 10, alignItems: "center" }}>
|
||||
<div
|
||||
style={{
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 8,
|
||||
background: "#f2f2f2",
|
||||
border: "1px solid #eee",
|
||||
overflow: "hidden",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
color: "#888",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{previews[a.id] ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={previews[a.id]} alt="" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
||||
) : (
|
||||
<span>{a.media_type}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", gap: 12 }}>
|
||||
<span>
|
||||
{a.media_type} · {a.status}
|
||||
</span>
|
||||
<span style={{ color: "#666", fontSize: 12 }}>{a.id.slice(0, 8)}</span>
|
||||
</div>
|
||||
{a.status === "failed" && a.error_message ? (
|
||||
<div style={{ color: "#b00", fontSize: 12, marginTop: 6 }}>{a.error_message}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{viewer || viewerError ? (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onClick={() => {
|
||||
setViewer(null);
|
||||
setViewerError(null);
|
||||
setVideoFallback(null);
|
||||
}}
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "rgba(0,0,0,0.6)",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
padding: 16,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: "min(1000px, 98vw)",
|
||||
maxHeight: "90vh",
|
||||
overflow: "auto",
|
||||
background: "white",
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
display: "grid",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline" }}>
|
||||
<strong>{viewer ? `${viewer.asset.media_type} (${viewer.variant})` : "Viewer"}</strong>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setViewer(null);
|
||||
setViewerError(null);
|
||||
setVideoFallback(null);
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{viewer ? (
|
||||
<>
|
||||
{viewer.asset.media_type === "image" ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={viewer.url}
|
||||
alt={viewer.asset.id}
|
||||
style={{ width: "100%", height: "auto" }}
|
||||
onError={() => setViewerError("image_load_failed")}
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
src={viewer.url}
|
||||
controls
|
||||
style={{ width: "100%" }}
|
||||
poster={videoFallback?.posterUrl ?? undefined}
|
||||
onError={() => {
|
||||
setViewerError("video_playback_failed");
|
||||
if (videoFallback !== null) return;
|
||||
setVideoFallback({ posterUrl: null });
|
||||
void loadSignedUrl(viewer.asset.id, "poster")
|
||||
.then((posterUrl) => setVideoFallback({ posterUrl }))
|
||||
.catch(() => setVideoFallback({ posterUrl: null }));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewerError ? (
|
||||
<div style={{ color: "#b00", fontSize: 12 }}>
|
||||
{viewerError}
|
||||
{viewer.asset.media_type === "video" ? " (try a different browser/codec)" : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div style={{ color: "#666", fontSize: 12 }}>{viewer.asset.id}</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ color: "#b00" }}>{viewerError ?? "unknown_error"}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
368
apps/web/app/components/TimelineTree.tsx
Normal file
368
apps/web/app/components/TimelineTree.tsx
Normal file
@@ -0,0 +1,368 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
type Granularity = "year" | "month" | "day";
|
||||
|
||||
type TreeNode = {
|
||||
id: string;
|
||||
label: string;
|
||||
ts: string; // ISO string from API
|
||||
countTotal: number;
|
||||
countReady: number;
|
||||
children?: TreeNode[];
|
||||
};
|
||||
|
||||
type ApiTreeRow = {
|
||||
bucket: string;
|
||||
group_ts: string;
|
||||
count_total: number;
|
||||
count_ready: number;
|
||||
};
|
||||
|
||||
type ApiTreeResponse = {
|
||||
granularity: Granularity;
|
||||
start: string | null;
|
||||
end: string | null;
|
||||
nodes: ApiTreeRow[];
|
||||
};
|
||||
|
||||
type Orientation = "vertical" | "horizontal";
|
||||
|
||||
type ExpandedState = Record<string, boolean>;
|
||||
|
||||
function monthKey(d: Date) {
|
||||
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function dayKey(d: Date) {
|
||||
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function yearKey(d: Date) {
|
||||
return `${d.getUTCFullYear()}`;
|
||||
}
|
||||
|
||||
function buildHierarchy(dayRows: ApiTreeRow[]): TreeNode[] {
|
||||
const years = new Map<string, TreeNode>();
|
||||
const months = new Map<string, TreeNode>();
|
||||
|
||||
for (const row of dayRows) {
|
||||
const date = new Date(row.group_ts);
|
||||
const year = yearKey(date);
|
||||
const month = monthKey(date);
|
||||
const day = dayKey(date);
|
||||
|
||||
const yMapKey = `${row.bucket}:${year}`;
|
||||
const mMapKey = `${row.bucket}:${month}`;
|
||||
|
||||
let yNode = years.get(yMapKey);
|
||||
if (!yNode) {
|
||||
yNode = {
|
||||
id: `y:${year}:${row.bucket}`,
|
||||
label: year,
|
||||
ts: new Date(Date.UTC(Number(year), 0, 1)).toISOString(),
|
||||
countTotal: 0,
|
||||
countReady: 0,
|
||||
children: [],
|
||||
};
|
||||
years.set(yMapKey, yNode);
|
||||
}
|
||||
|
||||
let mNode = months.get(mMapKey);
|
||||
if (!mNode) {
|
||||
const [yy, mm] = month.split("-");
|
||||
mNode = {
|
||||
id: `m:${month}:${row.bucket}`,
|
||||
label: `${yy}-${mm}`,
|
||||
ts: new Date(Date.UTC(Number(yy), Number(mm) - 1, 1)).toISOString(),
|
||||
countTotal: 0,
|
||||
countReady: 0,
|
||||
children: [],
|
||||
};
|
||||
months.set(mMapKey, mNode);
|
||||
yNode.children!.push(mNode);
|
||||
}
|
||||
|
||||
const dNode: TreeNode = {
|
||||
id: `d:${day}:${row.bucket}`,
|
||||
label: day,
|
||||
ts: row.group_ts,
|
||||
countTotal: row.count_total,
|
||||
countReady: row.count_ready,
|
||||
};
|
||||
|
||||
mNode.children!.push(dNode);
|
||||
|
||||
// rollups
|
||||
mNode.countTotal += row.count_total;
|
||||
mNode.countReady += row.count_ready;
|
||||
yNode.countTotal += row.count_total;
|
||||
yNode.countReady += row.count_ready;
|
||||
}
|
||||
|
||||
// Sort ascending within each parent
|
||||
const yearNodes = Array.from(years.values()).sort((a, b) => a.label.localeCompare(b.label));
|
||||
for (const y of yearNodes) {
|
||||
y.children = (y.children ?? []).sort((a, b) => a.label.localeCompare(b.label));
|
||||
for (const m of y.children) {
|
||||
m.children = (m.children ?? []).sort((a, b) => a.label.localeCompare(b.label));
|
||||
}
|
||||
}
|
||||
|
||||
return yearNodes;
|
||||
}
|
||||
|
||||
function gatherVisible(
|
||||
roots: TreeNode[],
|
||||
expanded: ExpandedState,
|
||||
): Array<{ node: TreeNode; depth: number; parentId: string | null }> {
|
||||
const out: Array<{ node: TreeNode; depth: number; parentId: string | null }> = [];
|
||||
|
||||
function walk(nodes: TreeNode[], depth: number, parentId: string | null) {
|
||||
for (const n of nodes) {
|
||||
out.push({ node: n, depth, parentId });
|
||||
const isExpanded = expanded[n.id] ?? depth < 1; // default expand years
|
||||
if (n.children?.length && isExpanded) {
|
||||
walk(n.children, depth + 1, n.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(roots, 0, null);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function TimelineTree(props: { onSelectDay?: (dayIso: string) => void }) {
|
||||
const [orientation, setOrientation] = useState<Orientation>("vertical");
|
||||
const [expanded, setExpanded] = useState<ExpandedState>({});
|
||||
const [rows, setRows] = useState<ApiTreeRow[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// simple pan/zoom via viewBox
|
||||
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||
const [viewBox, setViewBox] = useState({ x: 0, y: 0, w: 1200, h: 800 });
|
||||
const dragState = useRef<{
|
||||
active: boolean;
|
||||
startX: number;
|
||||
startY: number;
|
||||
startViewBox: typeof viewBox;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function load() {
|
||||
try {
|
||||
setError(null);
|
||||
const res = await fetch("/api/tree?granularity=day&limit=500&includeFailed=1", {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) throw new Error(`tree_fetch_failed:${res.status}`);
|
||||
const json = (await res.json()) as ApiTreeResponse;
|
||||
if (!cancelled) setRows(json.nodes);
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
void load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const roots = useMemo(() => (rows ? buildHierarchy(rows) : []), [rows]);
|
||||
const visible = useMemo(() => gatherVisible(roots, expanded), [roots, expanded]);
|
||||
|
||||
const layout = useMemo(() => {
|
||||
const nodeGap = 56;
|
||||
const depthGap = 180;
|
||||
|
||||
const positions = new Map<string, { x: number; y: number }>();
|
||||
|
||||
for (let i = 0; i < visible.length; i++) {
|
||||
const { node, depth } = visible[i];
|
||||
const primary = i * nodeGap;
|
||||
const secondary = depth * depthGap;
|
||||
|
||||
const x = orientation === "vertical" ? secondary : primary;
|
||||
const y = orientation === "vertical" ? primary : secondary;
|
||||
|
||||
positions.set(node.id, { x, y });
|
||||
}
|
||||
|
||||
// compute bounds
|
||||
let maxX = 0;
|
||||
let maxY = 0;
|
||||
for (const p of positions.values()) {
|
||||
maxX = Math.max(maxX, p.x);
|
||||
maxY = Math.max(maxY, p.y);
|
||||
}
|
||||
|
||||
const padding = 120;
|
||||
const w = maxX + padding * 2;
|
||||
const h = maxY + padding * 2;
|
||||
|
||||
return { positions, w, h, padding };
|
||||
}, [visible, orientation]);
|
||||
|
||||
useEffect(() => {
|
||||
// reset viewBox when layout changes
|
||||
setViewBox((vb) => ({ ...vb, w: Math.max(layout.w, 1200), h: Math.max(layout.h, 800) }));
|
||||
}, [layout.w, layout.h]);
|
||||
|
||||
function toggleNode(id: string) {
|
||||
setExpanded((prev) => ({ ...prev, [id]: !(prev[id] ?? false) }));
|
||||
}
|
||||
|
||||
function onPointerDown(e: React.PointerEvent<SVGSVGElement>) {
|
||||
(e.currentTarget as SVGSVGElement).setPointerCapture(e.pointerId);
|
||||
dragState.current = {
|
||||
active: true,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
startViewBox: viewBox,
|
||||
};
|
||||
}
|
||||
|
||||
function onPointerMove(e: React.PointerEvent<SVGSVGElement>) {
|
||||
if (!dragState.current?.active) return;
|
||||
const dx = e.clientX - dragState.current.startX;
|
||||
const dy = e.clientY - dragState.current.startY;
|
||||
|
||||
// Convert pixels to viewBox units roughly; this is intentionally simple.
|
||||
const scaleX = viewBox.w / 900;
|
||||
const scaleY = viewBox.h / 600;
|
||||
|
||||
setViewBox({
|
||||
...viewBox,
|
||||
x: dragState.current.startViewBox.x - dx * scaleX,
|
||||
y: dragState.current.startViewBox.y - dy * scaleY,
|
||||
});
|
||||
}
|
||||
|
||||
function onPointerUp(e: React.PointerEvent<SVGSVGElement>) {
|
||||
dragState.current = null;
|
||||
try {
|
||||
(e.currentTarget as SVGSVGElement).releasePointerCapture(e.pointerId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function onWheel(e: React.WheelEvent<SVGSVGElement>) {
|
||||
e.preventDefault();
|
||||
const factor = e.deltaY < 0 ? 0.9 : 1.1;
|
||||
|
||||
const mouseX = e.clientX;
|
||||
const mouseY = e.clientY;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
|
||||
const px = (mouseX - rect.left) / rect.width;
|
||||
const py = (mouseY - rect.top) / rect.height;
|
||||
|
||||
const newW = Math.max(300, Math.min(5000, viewBox.w * factor));
|
||||
const newH = Math.max(300, Math.min(5000, viewBox.h * factor));
|
||||
|
||||
const newX = viewBox.x + (viewBox.w - newW) * px;
|
||||
const newY = viewBox.y + (viewBox.h - newH) * py;
|
||||
|
||||
setViewBox({ x: newX, y: newY, w: newW, h: newH });
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid", gap: 12 }}>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
|
||||
<strong>Timeline</strong>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOrientation((o) => (o === "vertical" ? "horizontal" : "vertical"))}
|
||||
>
|
||||
Orientation: {orientation}
|
||||
</button>
|
||||
<button type="button" onClick={() => setViewBox({ x: 0, y: 0, w: 1200, h: 800 })}>
|
||||
Reset view
|
||||
</button>
|
||||
{rows ? <span style={{ color: "#666" }}>{rows.length} day nodes</span> : null}
|
||||
</div>
|
||||
|
||||
{error ? <div style={{ color: "#b00" }}>Error: {error}</div> : null}
|
||||
{!rows && !error ? <div style={{ color: "#666" }}>Loading tree…</div> : null}
|
||||
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
height: 600,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`${viewBox.x} ${viewBox.y} ${viewBox.w} ${viewBox.h}`}
|
||||
width="100%"
|
||||
height="100%"
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onWheel={onWheel}
|
||||
style={{ touchAction: "none", background: "#fafafa" }}
|
||||
>
|
||||
<g transform={`translate(${layout.padding} ${layout.padding})`}>
|
||||
{/* edges */}
|
||||
{visible.map(({ node, parentId }) => {
|
||||
if (!parentId) return null;
|
||||
const p = layout.positions.get(parentId);
|
||||
const c = layout.positions.get(node.id);
|
||||
if (!p || !c) return null;
|
||||
return (
|
||||
<line
|
||||
key={`${parentId}->${node.id}`}
|
||||
x1={p.x}
|
||||
y1={p.y}
|
||||
x2={c.x}
|
||||
y2={c.y}
|
||||
stroke="#bbb"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* nodes */}
|
||||
{visible.map(({ node }) => {
|
||||
const pos = layout.positions.get(node.id);
|
||||
if (!pos) return null;
|
||||
|
||||
const hasChildren = Boolean(node.children?.length);
|
||||
const isExpanded = expanded[node.id] ?? node.id.startsWith("y:");
|
||||
|
||||
const isDay = node.id.startsWith("d:");
|
||||
const clickCursor = hasChildren || isDay ? "pointer" : "default";
|
||||
|
||||
return (
|
||||
<g
|
||||
key={node.id}
|
||||
transform={`translate(${pos.x} ${pos.y})`}
|
||||
style={{ cursor: clickCursor }}
|
||||
onClick={() => {
|
||||
if (hasChildren) toggleNode(node.id);
|
||||
if (isDay) props.onSelectDay?.(node.ts);
|
||||
}}
|
||||
>
|
||||
<circle r={12} fill={hasChildren ? "#1f6feb" : "#666"} />
|
||||
<text x={20} y={5} fontSize={14} fill="#111">
|
||||
{node.label} ({node.countReady}/{node.countTotal})
|
||||
{hasChildren ? (isExpanded ? " ▼" : " ▶") : ""}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div style={{ color: "#666", fontSize: 12 }}>
|
||||
Drag to pan, mouse wheel to zoom. Click years/months to expand.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user