242 lines
6.6 KiB
TypeScript
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>
|
|
);
|
|
}
|