import type { PointLayerSpec, PolylineLayerSpec, ViewportTransform } from "./types"; export interface PlacedPointLabel { id: string; sx: number; sy: number; labelX: number; labelY: number; textAnchor: "start" | "middle" | "end"; titleText: string; coordText: string; color: string; radius: number; mergedWith?: string[]; needsLeaderLine: boolean; } interface BoundingBox { x1: number; y1: number; x2: number; y2: number; } /** * Định dạng số gọn: bỏ ".0" nếu là số nguyên, giữ tối đa 2 chữ số thập phân nếu lẻ. */ export function CompactSublabel(raw?: string): string { if (!raw) return ""; return raw.replace(/(\d+)\.0(?!\d)/g, "$1"); } function boxesOverlapArea(a: BoundingBox, b: BoundingBox, pad = 4): number { const xOverlap = Math.max(0, Math.min(a.x2 + pad, b.x2 + pad) - Math.max(a.x1 - pad, b.x1 - pad)); const yOverlap = Math.max(0, Math.min(a.y2 + pad, b.y2 + pad) - Math.max(a.y1 - pad, b.y1 - pad)); return xOverlap * yOverlap; } function distToSegment(px: number, py: number, x1: number, y1: number, x2: number, y2: number): number { const dx = x2 - x1; const dy = y2 - y1; const lenSq = dx * dx + dy * dy; if (lenSq < 1e-6) return Math.hypot(px - x1, py - y1); const t = Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / lenSq)); return Math.hypot(px - (x1 + t * dx), py - (y1 + t * dy)); } /** * Tự động gộp các điểm trùng tọa độ (ví dụ khi B trùng A hoặc B trùng C) * và tìm vị trí đặt nhãn tối ưu trong 8 hướng xung quanh mỗi điểm để không đè lên nhau, * không đè lên các chấm điểm khác, và tránh cắt ngang đường ngân sách. */ export function resolvePointLabels( points: PointLayerSpec[] | undefined, polylines: PolylineLayerSpec[] | undefined, viewport: ViewportTransform, compactMode: boolean ): PlacedPointLabel[] { if (!points || points.length === 0) return []; const plotLeft = viewport.padding.left; const plotRight = viewport.width - viewport.padding.right; const plotTop = viewport.padding.top; const plotBottom = viewport.height - viewport.padding.bottom; // 1. Gộp các điểm có khoảng cách màn hình < 6px (ví dụ A và B trùng nhau khi hạn mức không ràng buộc) const groups: { primary: PointLayerSpec; sx: number; sy: number; labels: string[]; sublabel: string; }[] = []; for (const pt of points) { const sx = viewport.toScreenX(pt.x); const sy = viewport.toScreenY(pt.y); const cleanSub = CompactSublabel(pt.sublabel); const existing = groups.find((g) => Math.hypot(g.sx - sx, g.sy - sy) < 6); if (existing) { if (!existing.labels.includes(pt.label)) { existing.labels.push(pt.label); } // Ưu tiên giữ màu của điểm mới (thường là B hoặc C đứng sau A) existing.primary = pt; } else { groups.push({ primary: pt, sx, sy, labels: [pt.label], sublabel: cleanSub, }); } } // Chuẩn bị danh sách đoạn thẳng từ polylines để tránh đặt chữ đè lên đường ngân sách const segments: { x1: number; y1: number; x2: number; y2: number }[] = []; if (polylines) { for (const poly of polylines) { for (let i = 0; i < poly.points.length - 1; i++) { segments.push({ x1: viewport.toScreenX(poly.points[i].x), y1: viewport.toScreenY(poly.points[i].y), x2: viewport.toScreenX(poly.points[i + 1].x), y2: viewport.toScreenY(poly.points[i + 1].y), }); } } } // 10 hướng ứng viên quanh điểm (dx, dy, anchor) const candidates: { dx: number; dy: number; anchor: "start" | "middle" | "end" }[] = [ { dx: 11, dy: -11, anchor: "start" }, // Đông Bắc (ưu tiên số 1 chuẩn sách giáo khoa) { dx: 12, dy: 14, anchor: "start" }, // Đông Nam { dx: -11, dy: -11, anchor: "end" }, // Tây Bắc { dx: -12, dy: 14, anchor: "end" }, // Tây Nam { dx: 0, dy: -15, anchor: "middle" }, // Bắc { dx: 0, dy: 19, anchor: "middle" }, // Nam { dx: 15, dy: 3, anchor: "start" }, // Đông { dx: -15, dy: 3, anchor: "end" }, // Tây { dx: 22, dy: -22, anchor: "start" }, // Đông Bắc xa (khi kẹt) { dx: -22, dy: -22, anchor: "end" }, // Tây Bắc xa (khi kẹt) ]; const placedBoxes: BoundingBox[] = []; const results: PlacedPointLabel[] = []; for (const grp of groups) { const titleText = grp.labels.join(" ≡ "); const coordText = compactMode ? "" : grp.sublabel ? grp.sublabel.startsWith("(") ? ` ${grp.sublabel}` : ` (${grp.sublabel})` : ""; const fullText = `${titleText}${coordText}`; const estWidth = Math.max(18, fullText.length * 6.8 + 6); const estHeight = 16; let bestCand = candidates[0]; let bestScore = Infinity; for (let cIdx = 0; cIdx < candidates.length; cIdx++) { const cand = candidates[cIdx]; const lx = grp.sx + cand.dx; const ly = grp.sy + cand.dy; let x1 = lx; let x2 = lx + estWidth; if (cand.anchor === "middle") { x1 = lx - estWidth / 2; x2 = lx + estWidth / 2; } else if (cand.anchor === "end") { x1 = lx - estWidth; x2 = lx; } const y1 = ly - estHeight / 2; const y2 = ly + estHeight / 2; const box: BoundingBox = { x1, y1, x2, y2 }; let penalty = cIdx * 8; // Ưu tiên nhẹ cho các hướng chuẩn (Đông Bắc trước) // Phạt nặng nếu tràn ra ngoài vùng vẽ đồ thị if (x1 < plotLeft + 4) penalty += (plotLeft + 4 - x1) * 40; if (x2 > plotRight - 4) penalty += (x2 - (plotRight - 4)) * 40; if (y1 < plotTop + 4) penalty += (plotTop + 4 - y1) * 40; if (y2 > plotBottom - 4) penalty += (y2 - (plotBottom - 4)) * 40; // Phạt rất nặng nếu đè lên nhãn đã đặt trước đó for (const prevBox of placedBoxes) { const overlap = boxesOverlapArea(box, prevBox, 5); if (overlap > 0) { penalty += 2000 + overlap * 20; } } // Phạt nặng nếu hộp chữ đè lên bất kỳ chấm tròn điểm nào for (const otherGrp of groups) { const dotBox: BoundingBox = { x1: otherGrp.sx - 8, y1: otherGrp.sy - 8, x2: otherGrp.sx + 8, y2: otherGrp.sy + 8, }; const dotOverlap = boxesOverlapArea(box, dotBox, 2); if (dotOverlap > 0) { penalty += 1500 + dotOverlap * 15; } } // Phạt nhẹ nếu tâm hộp chữ nằm quá sát đường ngân sách const centerX = (x1 + x2) / 2; const centerY = (y1 + y2) / 2; for (const seg of segments) { const d = distToSegment(centerX, centerY, seg.x1, seg.y1, seg.x2, seg.y2); if (d < 10) { penalty += (10 - d) * 18; } } if (penalty < bestScore) { bestScore = penalty; bestCand = cand; } } const finalLx = grp.sx + bestCand.dx; const finalLy = grp.sy + bestCand.dy; let fx1 = finalLx; let fx2 = finalLx + estWidth; if (bestCand.anchor === "middle") { fx1 = finalLx - estWidth / 2; fx2 = finalLx + estWidth / 2; } else if (bestCand.anchor === "end") { fx1 = finalLx - estWidth; fx2 = finalLx; } placedBoxes.push({ x1: fx1, y1: finalLy - estHeight / 2, x2: fx2, y2: finalLy + estHeight / 2, }); results.push({ id: grp.primary.id, sx: grp.sx, sy: grp.sy, labelX: finalLx, labelY: finalLy, textAnchor: bestCand.anchor, titleText, coordText, color: grp.primary.color, radius: grp.primary.radius ?? 5.5, mergedWith: grp.labels.length > 1 ? grp.labels : undefined, needsLeaderLine: Math.hypot(bestCand.dx, bestCand.dy) > 22, }); } return results; }