inogen/app/components/dashboard/d3-image-info.tsx

242 lines
6.6 KiB
TypeScript

"use client";
import React, { useEffect, useRef, useState } from "react";
import * as d3 from "d3";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
export type D3ImageInfoProps = {
imageUrl?: string;
title?: string;
description?: string;
width?: number; // fallback width if container size not measured yet
height?: number; // fallback height
};
/**
* D3ImageInfo
* - Renders an image and an information box beside it using D3 within an SVG.
* - Includes a clickable "show" chip that opens a popup dialog with more details.
*/
export function D3ImageInfo({
imageUrl = "/placeholder.svg",
title = "عنوان آیتم",
description = "توضیحات تکمیلی در مورد این آیتم در این قسمت نمایش داده می‌شود.",
width = 800,
height = 360,
}: D3ImageInfoProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const svgRef = useRef<SVGSVGElement | null>(null);
const [open, setOpen] = useState(false);
// Redraw helper
const draw = () => {
if (!containerRef.current || !svgRef.current) return;
const container = containerRef.current;
const svg = d3.select(svgRef.current);
const W = Math.max(480, container.clientWidth || width);
const H = Math.max(260, height);
svg.attr("width", W).attr("height", H);
// Clear previous content
svg.selectAll("*").remove();
// Layout
const padding = 16;
const imageAreaWidth = Math.min(300, Math.max(220, W * 0.35));
const infoAreaX = padding + imageAreaWidth + padding;
const infoAreaWidth = W - infoAreaX - padding;
// Image area (with rounded border)
const imgGroup = svg
.append("g")
.attr("transform", `translate(${padding}, ${padding})`);
const imgW = imageAreaWidth;
const imgH = H - 2 * padding;
// Frame
imgGroup
.append("rect")
.attr("width", imgW)
.attr("height", imgH)
.attr("rx", 10)
.attr("ry", 10)
.attr("fill", "#1F2937") // gray-800
.attr("stroke", "#4B5563") // gray-600
.attr("stroke-width", 1.5);
// Image
imgGroup
.append("image")
.attr("href", imageUrl)
.attr("x", 4)
.attr("y", 4)
.attr("width", imgW - 8)
.attr("height", imgH - 8)
.attr("preserveAspectRatio", "xMidYMid slice")
.attr("clip-path", null);
// Info area
const infoGroup = svg
.append("g")
.attr("transform", `translate(${infoAreaX}, ${padding})`);
// Info container
infoGroup
.append("rect")
.attr("width", Math.max(220, infoAreaWidth))
.attr("height", imgH)
.attr("rx", 12)
.attr("ry", 12)
.attr("fill", "url(#infoGradient)")
.attr("stroke", "#6B7280") // gray-500
.attr("stroke-width", 1);
// Background gradient
const defs = svg.append("defs");
const gradient = defs
.append("linearGradient")
.attr("id", "infoGradient")
.attr("x1", "0%")
.attr("y1", "0%")
.attr("x2", "0%")
.attr("y2", "100%");
gradient.append("stop").attr("offset", "0%").attr("stop-color", "#111827"); // gray-900
gradient
.append("stop")
.attr("offset", "100%")
.attr("stop-color", "#374151"); // gray-700
// Title
infoGroup
.append("text")
.attr("x", 16)
.attr("y", 36)
.attr("fill", "#F9FAFB") // gray-50
.attr("font-weight", 700)
.attr("font-size", 18)
.text(title);
// Description (wrapped)
const wrapText = (text: string, maxWidth: number) => {
const words = text.split(/\s+/).reverse();
const lines: string[] = [];
let line: string[] = [];
let t = "";
while (words.length) {
const word = words.pop()!;
const test = (t + " " + word).trim();
// Approximate measure using character count
const tooLong = test.length * 8 > maxWidth; // 8px avg char width
if (tooLong && t.length) {
lines.push(t);
t = word;
} else {
t = test;
}
}
if (t) lines.push(t);
return lines;
};
const descMaxWidth = Math.max(200, infoAreaWidth - 32);
const descLines = wrapText(description, descMaxWidth);
descLines.forEach((line, i) => {
infoGroup
.append("text")
.attr("x", 16)
.attr("y", 70 + i * 22)
.attr("fill", "#E5E7EB") // gray-200
.attr("font-size", 14)
.text(line);
});
// Show button-like chip
const chipY = Math.min(imgH - 48, 70 + descLines.length * 22 + 16);
const chip = infoGroup
.append("g")
.attr("class", "show-chip")
.style("cursor", "pointer");
const chipW = 120;
const chipH = 36;
chip
.append("rect")
.attr("x", 16)
.attr("y", chipY)
.attr("width", chipW)
.attr("height", chipH)
.attr("rx", 8)
.attr("ry", 8)
.attr("fill", "#3B82F6") // blue-500
.attr("stroke", "#60A5FA")
.attr("stroke-width", 1.5)
.attr("opacity", 0.95);
chip
.append("text")
.attr("x", 16 + chipW / 2)
.attr("y", chipY + chipH / 2 + 5)
.attr("text-anchor", "middle")
.attr("fill", "#FFFFFF")
.attr("font-weight", 700)
.text("نمایش");
// Hover & click
chip
.on("mouseenter", function () {
d3.select(this).select("rect").attr("fill", "#2563EB"); // blue-600
})
.on("mouseleave", function () {
d3.select(this).select("rect").attr("fill", "#3B82F6"); // blue-500
})
.on("click", () => setOpen(true));
};
useEffect(() => {
const ro = new ResizeObserver(() => draw());
if (containerRef.current) ro.observe(containerRef.current);
draw();
return () => ro.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [imageUrl, title, description]);
return (
<div className="w-full h-full">
<div ref={containerRef} className="w-full h-[380px]">
<svg ref={svgRef} className="block w-full h-full"></svg>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle className="font-persian">{title}</DialogTitle>
<DialogDescription className="font-persian">
{description}
</DialogDescription>
</DialogHeader>
<div className="mt-4">
<img
src={imageUrl}
alt={title}
className="w-full h-60 object-cover rounded-md border border-gray-700"
/>
</div>
</DialogContent>
</Dialog>
</div>
);
}