|
@@ -0,0 +1,1360 @@
|
|
|
|
|
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
|
|
|
+import { Alert, Button, Checkbox, DatePicker, Select, Spin, Tooltip } from "antd";
|
|
|
|
|
+import {
|
|
|
|
|
+ CaretDownFilled,
|
|
|
|
|
+ CaretRightFilled,
|
|
|
|
|
+ ReloadOutlined,
|
|
|
|
|
+ ShrinkOutlined,
|
|
|
|
|
+ ZoomInOutlined,
|
|
|
|
|
+ ZoomOutOutlined,
|
|
|
|
|
+ CompressOutlined,
|
|
|
|
|
+ FullscreenExitOutlined,
|
|
|
|
|
+ FullscreenOutlined,
|
|
|
|
|
+} from "@ant-design/icons";
|
|
|
|
|
+import dayjs from "dayjs";
|
|
|
|
|
+import type { Dayjs } from "dayjs";
|
|
|
|
|
+
|
|
|
|
|
+const API_BASE_URL =
|
|
|
|
|
+ import.meta.env.VITE_API_BASE_URL ?? "/demand/api/v1";
|
|
|
|
|
+
|
|
|
|
|
+const NODE_WIDTH = 236;
|
|
|
|
|
+const NODE_HEIGHT = 58;
|
|
|
|
|
+const COLUMN_GAP = 96;
|
|
|
|
|
+const ROW_GAP = 12;
|
|
|
|
|
+const CANVAS_PADDING = 48;
|
|
|
|
|
+const LEVEL_HEADER_HEIGHT = 44;
|
|
|
|
|
+const MIN_ZOOM = 0.08;
|
|
|
|
|
+const MAX_ZOOM = 2;
|
|
|
|
|
+const DEFAULT_EXPAND_LEVEL = 4;
|
|
|
|
|
+const DEFAULT_ZOOM = 0.7;
|
|
|
|
|
+
|
|
|
|
|
+const getResolvedApiBaseUrl = () => {
|
|
|
|
|
+ if (API_BASE_URL.startsWith("http://") || API_BASE_URL.startsWith("https://")) {
|
|
|
|
|
+ return API_BASE_URL;
|
|
|
|
|
+ }
|
|
|
|
|
+ return new URL(API_BASE_URL, window.location.origin).toString();
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+type CategoryNode = {
|
|
|
|
|
+ category_id: string;
|
|
|
|
|
+ parent_stable_id: string | null;
|
|
|
|
|
+ category_name: string | null;
|
|
|
|
|
+ category_level: number | null;
|
|
|
|
|
+ vid_count: number | null;
|
|
|
|
|
+ rov_score: number | null;
|
|
|
|
|
+ is_leaf: boolean;
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+type ElementNode = {
|
|
|
|
|
+ element_id: string;
|
|
|
|
|
+ stable_id: string | null;
|
|
|
|
|
+ element_name: string | null;
|
|
|
|
|
+ vid_count: number | null;
|
|
|
|
|
+ rov_score: number | null;
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+type TreeResponse = {
|
|
|
|
|
+ dt: string;
|
|
|
|
|
+ available_dates: string[];
|
|
|
|
|
+ category_min_rov_score: number;
|
|
|
|
|
+ category_max_rov_score: number;
|
|
|
|
|
+ element_min_rov_score: number;
|
|
|
|
|
+ element_max_rov_score: number;
|
|
|
|
|
+ categories: CategoryNode[];
|
|
|
|
|
+ elements: ElementNode[];
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+type LayoutKind = "category" | "element";
|
|
|
|
|
+
|
|
|
|
|
+type LayoutNode = {
|
|
|
|
|
+ key: string;
|
|
|
|
|
+ kind: LayoutKind;
|
|
|
|
|
+ depth: number;
|
|
|
|
|
+ x: number;
|
|
|
|
|
+ y: number;
|
|
|
|
|
+ category?: CategoryNode;
|
|
|
|
|
+ element?: ElementNode;
|
|
|
|
|
+ parentKey: string | null;
|
|
|
|
|
+ childKeys: string[];
|
|
|
|
|
+ categoryChildKeys: string[];
|
|
|
|
|
+ elementChildKeys: string[];
|
|
|
|
|
+ hasCategoryChildren: boolean;
|
|
|
|
|
+ hasElements: boolean;
|
|
|
|
|
+ categoryExpanded: boolean;
|
|
|
|
|
+ elementsExpanded: boolean;
|
|
|
|
|
+ categoryBadgeCount: number;
|
|
|
|
|
+ elementBadgeCount: number;
|
|
|
|
|
+ rov_score: number | null;
|
|
|
|
|
+ title: string;
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+type LevelHeader = {
|
|
|
|
|
+ depth: number;
|
|
|
|
|
+ label: string;
|
|
|
|
|
+ x: number;
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+function formatDtLabel(dt: string): string {
|
|
|
|
|
+ if (/^\d{8}$/.test(dt)) {
|
|
|
|
|
+ return `${dt.slice(0, 4)}-${dt.slice(4, 6)}-${dt.slice(6, 8)}`;
|
|
|
|
|
+ }
|
|
|
|
|
+ return dt;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function formatScore(value: number | null): string {
|
|
|
|
|
+ if (value === null || value === undefined || Number.isNaN(value)) {
|
|
|
|
|
+ return "-";
|
|
|
|
|
+ }
|
|
|
|
|
+ return value.toFixed(4);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function isZeroRovScore(score: number | null): boolean {
|
|
|
|
|
+ return score !== null && score !== undefined && !Number.isNaN(score) && Math.abs(score) < 1e-9;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const ZERO_ROV_STYLE = {
|
|
|
|
|
+ background: "linear-gradient(135deg, #e2e8f0 0%, #cbd5e1 100%)",
|
|
|
|
|
+ border: "#94a3b8",
|
|
|
|
|
+ text: "#64748b",
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+function rovToPastelStyle(
|
|
|
|
|
+ score: number | null,
|
|
|
|
|
+ minScore: number,
|
|
|
|
|
+ maxScore: number,
|
|
|
|
|
+): { background: string; border: string; text: string } {
|
|
|
|
|
+ if (score === null || score === undefined || Number.isNaN(score)) {
|
|
|
|
|
+ return {
|
|
|
|
|
+ background: "linear-gradient(135deg, #f8fafc 0%, #eef2f7 100%)",
|
|
|
|
|
+ border: "#dbe3ef",
|
|
|
|
|
+ text: "#64748b",
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (isZeroRovScore(score)) {
|
|
|
|
|
+ return ZERO_ROV_STYLE;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const span = maxScore - minScore;
|
|
|
|
|
+ const ratio = span <= 1e-9 ? 0.5 : (score - minScore) / span;
|
|
|
|
|
+ const clamped = Math.min(1, Math.max(0, ratio));
|
|
|
|
|
+
|
|
|
|
|
+ const red = { r: 252, g: 165, b: 165 };
|
|
|
|
|
+ const white = { r: 255, g: 255, b: 255 };
|
|
|
|
|
+ const green = { r: 134, g: 239, b: 172 };
|
|
|
|
|
+ let r: number;
|
|
|
|
|
+ let g: number;
|
|
|
|
|
+ let b: number;
|
|
|
|
|
+ if (clamped <= 0.5) {
|
|
|
|
|
+ const t = clamped / 0.5;
|
|
|
|
|
+ r = Math.round(red.r + (white.r - red.r) * t);
|
|
|
|
|
+ g = Math.round(red.g + (white.g - red.g) * t);
|
|
|
|
|
+ b = Math.round(red.b + (white.b - red.b) * t);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ const t = (clamped - 0.5) / 0.5;
|
|
|
|
|
+ r = Math.round(white.r + (green.r - white.r) * t);
|
|
|
|
|
+ g = Math.round(white.g + (green.g - white.g) * t);
|
|
|
|
|
+ b = Math.round(white.b + (green.b - white.b) * t);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ background: `linear-gradient(135deg, rgb(${r}, ${g}, ${b}) 0%, rgb(${Math.min(255, r + 18)}, ${Math.min(255, g + 18)}, ${Math.min(255, b + 18)}) 100%)`,
|
|
|
|
|
+ border: `rgba(${Math.round(r * 0.55)}, ${Math.round(g * 0.55)}, ${Math.round(b * 0.55)}, 0.55)`,
|
|
|
|
|
+ text: "#4a3728",
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function buildChildrenMap(categories: CategoryNode[]): Map<string, CategoryNode[]> {
|
|
|
|
|
+ const map = new Map<string, CategoryNode[]>();
|
|
|
|
|
+ for (const category of categories) {
|
|
|
|
|
+ const parentId = category.parent_stable_id;
|
|
|
|
|
+ if (!parentId) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ const siblings = map.get(parentId) ?? [];
|
|
|
|
|
+ siblings.push(category);
|
|
|
|
|
+ map.set(parentId, siblings);
|
|
|
|
|
+ }
|
|
|
|
|
+ for (const siblings of map.values()) {
|
|
|
|
|
+ siblings.sort((a, b) =>
|
|
|
|
|
+ a.category_id.localeCompare(b.category_id, undefined, { numeric: true }),
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+ return map;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function buildElementsByCategory(elements: ElementNode[]): Map<string, ElementNode[]> {
|
|
|
|
|
+ const map = new Map<string, ElementNode[]>();
|
|
|
|
|
+ for (const element of elements) {
|
|
|
|
|
+ const categoryId = element.stable_id;
|
|
|
|
|
+ if (!categoryId) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ const siblings = map.get(categoryId) ?? [];
|
|
|
|
|
+ siblings.push(element);
|
|
|
|
|
+ map.set(categoryId, siblings);
|
|
|
|
|
+ }
|
|
|
|
|
+ for (const siblings of map.values()) {
|
|
|
|
|
+ siblings.sort((a, b) =>
|
|
|
|
|
+ a.element_id.localeCompare(b.element_id, undefined, { numeric: true }),
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+ return map;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function isMissingParent(category: CategoryNode, allIds: Set<string>): boolean {
|
|
|
|
|
+ return !category.parent_stable_id || !allIds.has(category.parent_stable_id);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function findRootCategories(categories: CategoryNode[]): CategoryNode[] {
|
|
|
|
|
+ const allIds = new Set(categories.map((item) => item.category_id));
|
|
|
|
|
+ return categories
|
|
|
|
|
+ .filter((item) => {
|
|
|
|
|
+ if (!isMissingParent(item, allIds)) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ const level = item.category_level ?? 1;
|
|
|
|
|
+ return level <= 1;
|
|
|
|
|
+ })
|
|
|
|
|
+ .sort((a, b) =>
|
|
|
|
|
+ a.category_id.localeCompare(b.category_id, undefined, { numeric: true }),
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function findDetachedCategories(categories: CategoryNode[]): CategoryNode[] {
|
|
|
|
|
+ const allIds = new Set(categories.map((item) => item.category_id));
|
|
|
|
|
+ return categories
|
|
|
|
|
+ .filter((item) => {
|
|
|
|
|
+ if (!isMissingParent(item, allIds)) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ const level = item.category_level ?? 1;
|
|
|
|
|
+ return level > 1;
|
|
|
|
|
+ })
|
|
|
|
|
+ .sort((a, b) => {
|
|
|
|
|
+ const levelA = a.category_level ?? 1;
|
|
|
|
|
+ const levelB = b.category_level ?? 1;
|
|
|
|
|
+ if (levelA !== levelB) {
|
|
|
|
|
+ return levelA - levelB;
|
|
|
|
|
+ }
|
|
|
|
|
+ return a.category_id.localeCompare(b.category_id, undefined, { numeric: true });
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function buildCategoryDepthMap(
|
|
|
|
|
+ categories: CategoryNode[],
|
|
|
|
|
+ childrenMap: Map<string, CategoryNode[]>,
|
|
|
|
|
+ roots: CategoryNode[],
|
|
|
|
|
+): Map<string, number> {
|
|
|
|
|
+ const depthMap = new Map<string, number>();
|
|
|
|
|
+ const detached = findDetachedCategories(categories);
|
|
|
|
|
+
|
|
|
|
|
+ const visit = (categoryId: string, fallbackLevel: number) => {
|
|
|
|
|
+ if (depthMap.has(categoryId)) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ const category = categories.find((item) => item.category_id === categoryId);
|
|
|
|
|
+ const level = category?.category_level ?? fallbackLevel;
|
|
|
|
|
+ depthMap.set(categoryId, level);
|
|
|
|
|
+ for (const child of childrenMap.get(categoryId) ?? []) {
|
|
|
|
|
+ visit(child.category_id, level + 1);
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ for (const root of roots) {
|
|
|
|
|
+ visit(root.category_id, root.category_level ?? 1);
|
|
|
|
|
+ }
|
|
|
|
|
+ for (const item of detached) {
|
|
|
|
|
+ if (!depthMap.has(item.category_id)) {
|
|
|
|
|
+ visit(item.category_id, item.category_level ?? 1);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ for (const category of categories) {
|
|
|
|
|
+ if (!depthMap.has(category.category_id)) {
|
|
|
|
|
+ depthMap.set(category.category_id, category.category_level ?? 1);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return depthMap;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function getEffectiveCategoryLevel(
|
|
|
|
|
+ category: CategoryNode,
|
|
|
|
|
+ depthMap: Map<string, number>,
|
|
|
|
|
+): number {
|
|
|
|
|
+ return category.category_level ?? depthMap.get(category.category_id) ?? 1;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function getLayoutDepth(category: CategoryNode, depthMap: Map<string, number>): number {
|
|
|
|
|
+ return Math.max(0, getEffectiveCategoryLevel(category, depthMap) - 1);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const EXPAND_ALL_LEVEL = "all" as const;
|
|
|
|
|
+type ExpandLevelFilter = number | typeof EXPAND_ALL_LEVEL;
|
|
|
|
|
+
|
|
|
|
|
+function isCategoryVisibleByExpandFilter(
|
|
|
|
|
+ category: CategoryNode,
|
|
|
|
|
+ depthMap: Map<string, number>,
|
|
|
|
|
+ levelFilter: ExpandLevelFilter,
|
|
|
|
|
+): boolean {
|
|
|
|
|
+ if (levelFilter === EXPAND_ALL_LEVEL) {
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+ return getEffectiveCategoryLevel(category, depthMap) <= levelFilter;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type RovScale = { min: number; max: number };
|
|
|
|
|
+
|
|
|
|
|
+function buildCategoryRovScalesByLevel(
|
|
|
|
|
+ categories: CategoryNode[],
|
|
|
|
|
+ depthMap: Map<string, number>,
|
|
|
|
|
+): Map<number, RovScale> {
|
|
|
|
|
+ const scales = new Map<number, RovScale>();
|
|
|
|
|
+ for (const category of categories) {
|
|
|
|
|
+ if (isZeroRovScore(category.rov_score) || category.rov_score === null) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ const level = getEffectiveCategoryLevel(category, depthMap);
|
|
|
|
|
+ const score = category.rov_score;
|
|
|
|
|
+ const current = scales.get(level);
|
|
|
|
|
+ if (!current) {
|
|
|
|
|
+ scales.set(level, { min: score, max: score });
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ current.min = Math.min(current.min, score);
|
|
|
|
|
+ current.max = Math.max(current.max, score);
|
|
|
|
|
+ }
|
|
|
|
|
+ return scales;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function buildElementRovScale(elements: ElementNode[]): RovScale {
|
|
|
|
|
+ let min = 0;
|
|
|
|
|
+ let max = 0;
|
|
|
|
|
+ let initialized = false;
|
|
|
|
|
+ for (const element of elements) {
|
|
|
|
|
+ if (isZeroRovScore(element.rov_score) || element.rov_score === null) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ const score = element.rov_score;
|
|
|
|
|
+ if (!initialized) {
|
|
|
|
|
+ min = score;
|
|
|
|
|
+ max = score;
|
|
|
|
|
+ initialized = true;
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ min = Math.min(min, score);
|
|
|
|
|
+ max = Math.max(max, score);
|
|
|
|
|
+ }
|
|
|
|
|
+ return { min, max };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function resolveNodeRovScale(
|
|
|
|
|
+ node: LayoutNode,
|
|
|
|
|
+ categoryRovScalesByLevel: Map<number, RovScale>,
|
|
|
|
|
+ elementRovScale: RovScale,
|
|
|
|
|
+ depthMap: Map<string, number>,
|
|
|
|
|
+): RovScale {
|
|
|
|
|
+ if (node.kind === "element") {
|
|
|
|
|
+ return elementRovScale;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!node.category) {
|
|
|
|
|
+ return { min: 0, max: 0 };
|
|
|
|
|
+ }
|
|
|
|
|
+ const level = getEffectiveCategoryLevel(node.category, depthMap);
|
|
|
|
|
+ return categoryRovScalesByLevel.get(level) ?? { min: 0, max: 0 };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function computeExpandedCategoryIds(
|
|
|
|
|
+ categories: CategoryNode[],
|
|
|
|
|
+ childrenMap: Map<string, CategoryNode[]>,
|
|
|
|
|
+ depthMap: Map<string, number>,
|
|
|
|
|
+ levelFilter: ExpandLevelFilter,
|
|
|
|
|
+): Set<string> {
|
|
|
|
|
+ const withChildren = categories.filter(
|
|
|
|
|
+ (item) => (childrenMap.get(item.category_id) ?? []).length > 0,
|
|
|
|
|
+ );
|
|
|
|
|
+ if (levelFilter === EXPAND_ALL_LEVEL) {
|
|
|
|
|
+ return new Set(withChildren.map((item) => item.category_id));
|
|
|
|
|
+ }
|
|
|
|
|
+ return new Set(
|
|
|
|
|
+ withChildren
|
|
|
|
|
+ .filter(
|
|
|
|
|
+ (item) => getEffectiveCategoryLevel(item, depthMap) < levelFilter,
|
|
|
|
|
+ )
|
|
|
|
|
+ .map((item) => item.category_id),
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function collectDescendantCategoryIds(
|
|
|
|
|
+ categoryId: string,
|
|
|
|
|
+ childrenMap: Map<string, CategoryNode[]>,
|
|
|
|
|
+): string[] {
|
|
|
|
|
+ const result: string[] = [];
|
|
|
|
|
+ const stack = [...(childrenMap.get(categoryId) ?? [])];
|
|
|
|
|
+ while (stack.length > 0) {
|
|
|
|
|
+ const current = stack.pop()!;
|
|
|
|
|
+ result.push(current.category_id);
|
|
|
|
|
+ stack.push(...(childrenMap.get(current.category_id) ?? []));
|
|
|
|
|
+ }
|
|
|
|
|
+ return result;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function buildConnectorPath(parent: LayoutNode, child: LayoutNode): string {
|
|
|
|
|
+ const x1 = parent.x + NODE_WIDTH;
|
|
|
|
|
+ const y1 = parent.y + NODE_HEIGHT / 2;
|
|
|
|
|
+ const x2 = child.x;
|
|
|
|
|
+ const y2 = child.y + NODE_HEIGHT / 2;
|
|
|
|
|
+ const midX = x1 + (x2 - x1) / 2;
|
|
|
|
|
+ return `M ${x1} ${y1} H ${midX} V ${y2} H ${x2}`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type TreeNodeCardProps = {
|
|
|
|
|
+ node: LayoutNode;
|
|
|
|
|
+ categoryRovScalesByLevel: Map<number, RovScale>;
|
|
|
|
|
+ elementRovScale: RovScale;
|
|
|
|
|
+ categoryDepthMap: Map<string, number>;
|
|
|
|
|
+ active: boolean;
|
|
|
|
|
+ onToggleCategory: () => void;
|
|
|
|
|
+ onToggleElements: () => void;
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+function TreeNodeCard({
|
|
|
|
|
+ node,
|
|
|
|
|
+ categoryRovScalesByLevel,
|
|
|
|
|
+ elementRovScale,
|
|
|
|
|
+ categoryDepthMap,
|
|
|
|
|
+ active,
|
|
|
|
|
+ onToggleCategory,
|
|
|
|
|
+ onToggleElements,
|
|
|
|
|
+}: TreeNodeCardProps) {
|
|
|
|
|
+ const { min: minScore, max: maxScore } = resolveNodeRovScale(
|
|
|
|
|
+ node,
|
|
|
|
|
+ categoryRovScalesByLevel,
|
|
|
|
|
+ elementRovScale,
|
|
|
|
|
+ categoryDepthMap,
|
|
|
|
|
+ );
|
|
|
|
|
+ const isZeroScore = isZeroRovScore(node.rov_score);
|
|
|
|
|
+ const palette = rovToPastelStyle(node.rov_score, minScore, maxScore);
|
|
|
|
|
+ const tooltip = (
|
|
|
|
|
+ <div className="cet-tooltip">
|
|
|
|
|
+ <div>{node.title}</div>
|
|
|
|
|
+ <div>ROV {formatScore(node.rov_score)}</div>
|
|
|
|
|
+ {isZeroScore ? <div className="cet-tooltip-note">得分为 0,不参与色阶</div> : null}
|
|
|
|
|
+ {node.kind === "category" && node.category?.vid_count != null ? (
|
|
|
|
|
+ <div>视频 {node.category.vid_count}</div>
|
|
|
|
|
+ ) : null}
|
|
|
|
|
+ {node.kind === "element" && node.element?.vid_count != null ? (
|
|
|
|
|
+ <div>视频 {node.element.vid_count}</div>
|
|
|
|
|
+ ) : null}
|
|
|
|
|
+ {node.hasElements ? (
|
|
|
|
|
+ <div>
|
|
|
|
|
+ {node.elementsExpanded
|
|
|
|
|
+ ? "已展开元素"
|
|
|
|
|
+ : `点击右侧「${node.elementBadgeCount} 元素」展开`}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : null}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ const handleMainClick = () => {
|
|
|
|
|
+ if (node.kind === "element") {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (node.hasCategoryChildren) {
|
|
|
|
|
+ onToggleCategory();
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (node.hasElements) {
|
|
|
|
|
+ onToggleElements();
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <Tooltip title={tooltip} placement="top" mouseEnterDelay={0.35}>
|
|
|
|
|
+ <div
|
|
|
|
|
+ className={`cet-node${active ? " cet-node--active" : ""}${node.kind === "element" ? " cet-node--element" : ""}${node.hasElements && !node.hasCategoryChildren ? " cet-node--leaf" : ""}${isZeroScore ? " cet-node--zero" : ""}`}
|
|
|
|
|
+ style={{
|
|
|
|
|
+ left: node.x,
|
|
|
|
|
+ top: node.y,
|
|
|
|
|
+ width: NODE_WIDTH,
|
|
|
|
|
+ height: NODE_HEIGHT,
|
|
|
|
|
+ background: palette.background,
|
|
|
|
|
+ borderColor: active ? "#3b82f6" : palette.border,
|
|
|
|
|
+ color: palette.text,
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ {node.hasCategoryChildren ? (
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ className="cet-node-caret-btn"
|
|
|
|
|
+ aria-label={node.categoryExpanded ? "收起子分类" : "展开子分类"}
|
|
|
|
|
+ onClick={(event) => {
|
|
|
|
|
+ event.stopPropagation();
|
|
|
|
|
+ onToggleCategory();
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ {node.categoryExpanded ? <CaretDownFilled /> : <CaretRightFilled />}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <span className="cet-node-caret cet-node-caret--placeholder" />
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ <button type="button" className="cet-node-main" onClick={handleMainClick}>
|
|
|
|
|
+ <span className="cet-node-title-row">
|
|
|
|
|
+ <span className="cet-node-label" title={node.title}>
|
|
|
|
|
+ {node.title}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <span
|
|
|
|
|
+ className={`cet-node-kind-tag${node.kind === "element" ? " cet-node-kind-tag--element" : " cet-node-kind-tag--category"}`}
|
|
|
|
|
+ >
|
|
|
|
|
+ {node.kind === "element" ? "元素" : "分类"}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <span className="cet-node-score">
|
|
|
|
|
+ ROV {formatScore(node.rov_score)}
|
|
|
|
|
+ {isZeroScore ? <span className="cet-node-zero-tag">无效果</span> : null}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </button>
|
|
|
|
|
+
|
|
|
|
|
+ <div className="cet-node-badges">
|
|
|
|
|
+ {node.hasCategoryChildren && !node.categoryExpanded ? (
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ className="cet-node-badge cet-node-badge--category"
|
|
|
|
|
+ onClick={(event) => {
|
|
|
|
|
+ event.stopPropagation();
|
|
|
|
|
+ onToggleCategory();
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ {node.categoryBadgeCount}
|
|
|
|
|
+ <CaretRightFilled />
|
|
|
|
|
+ </button>
|
|
|
|
|
+ ) : null}
|
|
|
|
|
+ {node.hasElements ? (
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ className={`cet-node-badge cet-node-badge--element${node.elementsExpanded ? " cet-node-badge--expanded" : ""}`}
|
|
|
|
|
+ onClick={(event) => {
|
|
|
|
|
+ event.stopPropagation();
|
|
|
|
|
+ onToggleElements();
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ {node.elementBadgeCount} 元素
|
|
|
|
|
+ {node.elementsExpanded ? <CaretDownFilled /> : <CaretRightFilled />}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ ) : null}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </Tooltip>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export default function CategoryEffectTreeApp() {
|
|
|
|
|
+ const [selectedDate, setSelectedDate] = useState<Dayjs | null>(null);
|
|
|
|
|
+ const [appliedDt, setAppliedDt] = useState("");
|
|
|
|
|
+ const [loading, setLoading] = useState(false);
|
|
|
|
|
+ const [error, setError] = useState("");
|
|
|
|
|
+ const [data, setData] = useState<TreeResponse | null>(null);
|
|
|
|
|
+ const [expandedCategoryIds, setExpandedCategoryIds] = useState<Set<string>>(new Set());
|
|
|
|
|
+ const [expandedElementParents, setExpandedElementParents] = useState<Set<string>>(new Set());
|
|
|
|
|
+ const [activeKey, setActiveKey] = useState<string | null>(null);
|
|
|
|
|
+ const [showInvalidNodes, setShowInvalidNodes] = useState(false);
|
|
|
|
|
+ const [expandLevelFilter, setExpandLevelFilter] = useState<ExpandLevelFilter>(DEFAULT_EXPAND_LEVEL);
|
|
|
|
|
+ const [zoom, setZoom] = useState(DEFAULT_ZOOM);
|
|
|
|
|
+ const [canvasScrollLeft, setCanvasScrollLeft] = useState(0);
|
|
|
|
|
+ const [treeFullscreen, setTreeFullscreen] = useState(false);
|
|
|
|
|
+ const viewportRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
|
+
|
|
|
|
|
+ const fetchTree = useCallback(async (dt?: string) => {
|
|
|
|
|
+ setLoading(true);
|
|
|
|
|
+ setError("");
|
|
|
|
|
+ try {
|
|
|
|
|
+ const resolvedBase = getResolvedApiBaseUrl();
|
|
|
|
|
+ const baseWithSlash = resolvedBase.endsWith("/")
|
|
|
|
|
+ ? resolvedBase
|
|
|
|
|
+ : `${resolvedBase}/`;
|
|
|
|
|
+ const url = new URL("vertical-category/tree", baseWithSlash);
|
|
|
|
|
+ if (dt) {
|
|
|
|
|
+ url.searchParams.set("dt", dt);
|
|
|
|
|
+ }
|
|
|
|
|
+ const response = await fetch(url.toString(), {
|
|
|
|
|
+ method: "GET",
|
|
|
|
|
+ headers: { Accept: "application/json" },
|
|
|
|
|
+ });
|
|
|
|
|
+ if (!response.ok) {
|
|
|
|
|
+ const detail = await response.text();
|
|
|
|
|
+ throw new Error(detail || `HTTP ${response.status}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ const payload = (await response.json()) as TreeResponse;
|
|
|
|
|
+ setData(payload);
|
|
|
|
|
+ setAppliedDt(payload.dt);
|
|
|
|
|
+ setSelectedDate(dayjs(payload.dt, "YYYYMMDD"));
|
|
|
|
|
+ setExpandedCategoryIds(new Set());
|
|
|
|
|
+ setExpandedElementParents(new Set());
|
|
|
|
|
+ setExpandLevelFilter(DEFAULT_EXPAND_LEVEL);
|
|
|
|
|
+ setZoom(DEFAULT_ZOOM);
|
|
|
|
|
+ setActiveKey(null);
|
|
|
|
|
+ } catch (queryError) {
|
|
|
|
|
+ setError(
|
|
|
|
|
+ queryError instanceof Error ? queryError.message : "查询失败,请重试",
|
|
|
|
|
+ );
|
|
|
|
|
+ setData(null);
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ setLoading(false);
|
|
|
|
|
+ }
|
|
|
|
|
+ }, []);
|
|
|
|
|
+
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ void fetchTree();
|
|
|
|
|
+ }, [fetchTree]);
|
|
|
|
|
+
|
|
|
|
|
+ const childrenMap = useMemo(
|
|
|
|
|
+ () => buildChildrenMap(data?.categories ?? []),
|
|
|
|
|
+ [data?.categories],
|
|
|
|
|
+ );
|
|
|
|
|
+ const elementsByCategory = useMemo(
|
|
|
|
|
+ () => buildElementsByCategory(data?.elements ?? []),
|
|
|
|
|
+ [data?.elements],
|
|
|
|
|
+ );
|
|
|
|
|
+ const roots = useMemo(
|
|
|
|
|
+ () => findRootCategories(data?.categories ?? []),
|
|
|
|
|
+ [data?.categories],
|
|
|
|
|
+ );
|
|
|
|
|
+ const detachedCategories = useMemo(
|
|
|
|
|
+ () => findDetachedCategories(data?.categories ?? []),
|
|
|
|
|
+ [data?.categories],
|
|
|
|
|
+ );
|
|
|
|
|
+ const categoryDepthMap = useMemo(
|
|
|
|
|
+ () => buildCategoryDepthMap(data?.categories ?? [], childrenMap, roots),
|
|
|
|
|
+ [data?.categories, childrenMap, roots],
|
|
|
|
|
+ );
|
|
|
|
|
+ const availableExpandLevels = useMemo(() => {
|
|
|
|
|
+ if (!data) {
|
|
|
|
|
+ return [1];
|
|
|
|
|
+ }
|
|
|
|
|
+ const levels = new Set<number>();
|
|
|
|
|
+ for (const category of data.categories) {
|
|
|
|
|
+ levels.add(getEffectiveCategoryLevel(category, categoryDepthMap));
|
|
|
|
|
+ }
|
|
|
|
|
+ return [...levels].sort((a, b) => a - b);
|
|
|
|
|
+ }, [data, categoryDepthMap]);
|
|
|
|
|
+
|
|
|
|
|
+ const expandLevelOptions = useMemo(() => {
|
|
|
|
|
+ const levelOptions = availableExpandLevels.map((level) => ({
|
|
|
|
|
+ value: level,
|
|
|
|
|
+ label: `L${level}`,
|
|
|
|
|
+ }));
|
|
|
|
|
+ return [
|
|
|
|
|
+ ...levelOptions,
|
|
|
|
|
+ { value: EXPAND_ALL_LEVEL, label: "全部展开" },
|
|
|
|
|
+ ];
|
|
|
|
|
+ }, [availableExpandLevels]);
|
|
|
|
|
+
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ if (!data) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ setExpandedCategoryIds(
|
|
|
|
|
+ computeExpandedCategoryIds(
|
|
|
|
|
+ data.categories,
|
|
|
|
|
+ childrenMap,
|
|
|
|
|
+ categoryDepthMap,
|
|
|
|
|
+ expandLevelFilter,
|
|
|
|
|
+ ),
|
|
|
|
|
+ );
|
|
|
|
|
+ }, [data, childrenMap, categoryDepthMap, expandLevelFilter]);
|
|
|
|
|
+
|
|
|
|
|
+ const handleExpandLevelFilterChange = (value: ExpandLevelFilter) => {
|
|
|
|
|
+ setExpandLevelFilter(value);
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const { layoutNodes, connectors, levelHeaders, canvasWidth, canvasHeight } = useMemo(() => {
|
|
|
|
|
+ if (!data) {
|
|
|
|
|
+ return {
|
|
|
|
|
+ layoutNodes: [] as LayoutNode[],
|
|
|
|
|
+ connectors: [] as string[],
|
|
|
|
|
+ levelHeaders: [] as LevelHeader[],
|
|
|
|
|
+ canvasWidth: 800,
|
|
|
|
|
+ canvasHeight: 480,
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const nodeMap = new Map<string, LayoutNode>();
|
|
|
|
|
+
|
|
|
|
|
+ const buildCategoryNode = (
|
|
|
|
|
+ category: CategoryNode,
|
|
|
|
|
+ depth: number,
|
|
|
|
|
+ parentKey: string | null,
|
|
|
|
|
+ options: {
|
|
|
|
|
+ categoryChildKeys: string[];
|
|
|
|
|
+ elementChildKeys: string[];
|
|
|
|
|
+ hasCategoryChildren: boolean;
|
|
|
|
|
+ hasElements: boolean;
|
|
|
|
|
+ categoryExpanded: boolean;
|
|
|
|
|
+ elementsExpanded: boolean;
|
|
|
|
|
+ categoryBadgeCount: number;
|
|
|
|
|
+ elementBadgeCount: number;
|
|
|
|
|
+ },
|
|
|
|
|
+ ): LayoutNode => {
|
|
|
|
|
+ const childKeys = [...options.categoryChildKeys, ...options.elementChildKeys];
|
|
|
|
|
+
|
|
|
|
|
+ const layoutNode: LayoutNode = {
|
|
|
|
|
+ key: `category:${category.category_id}`,
|
|
|
|
|
+ kind: "category",
|
|
|
|
|
+ depth,
|
|
|
|
|
+ x: depth * (NODE_WIDTH + COLUMN_GAP) + CANVAS_PADDING,
|
|
|
|
|
+ y: 0,
|
|
|
|
|
+ category,
|
|
|
|
|
+ parentKey,
|
|
|
|
|
+ childKeys,
|
|
|
|
|
+ categoryChildKeys: options.categoryChildKeys,
|
|
|
|
|
+ elementChildKeys: options.elementChildKeys,
|
|
|
|
|
+ hasCategoryChildren: options.hasCategoryChildren,
|
|
|
|
|
+ hasElements: options.hasElements,
|
|
|
|
|
+ categoryExpanded: options.categoryExpanded,
|
|
|
|
|
+ elementsExpanded: options.elementsExpanded,
|
|
|
|
|
+ categoryBadgeCount: options.categoryBadgeCount,
|
|
|
|
|
+ elementBadgeCount: options.elementBadgeCount,
|
|
|
|
|
+ rov_score: category.rov_score,
|
|
|
|
|
+ title: category.category_name ?? category.category_id,
|
|
|
|
|
+ };
|
|
|
|
|
+ nodeMap.set(layoutNode.key, layoutNode);
|
|
|
|
|
+ return layoutNode;
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const buildElementNode = (
|
|
|
|
|
+ element: ElementNode,
|
|
|
|
|
+ depth: number,
|
|
|
|
|
+ parentKey: string,
|
|
|
|
|
+ ): LayoutNode => {
|
|
|
|
|
+ const layoutNode: LayoutNode = {
|
|
|
|
|
+ key: `element:${element.element_id}`,
|
|
|
|
|
+ kind: "element",
|
|
|
|
|
+ depth,
|
|
|
|
|
+ x: depth * (NODE_WIDTH + COLUMN_GAP) + CANVAS_PADDING,
|
|
|
|
|
+ y: 0,
|
|
|
|
|
+ element,
|
|
|
|
|
+ parentKey,
|
|
|
|
|
+ childKeys: [],
|
|
|
|
|
+ categoryChildKeys: [],
|
|
|
|
|
+ elementChildKeys: [],
|
|
|
|
|
+ hasCategoryChildren: false,
|
|
|
|
|
+ hasElements: false,
|
|
|
|
|
+ categoryExpanded: false,
|
|
|
|
|
+ elementsExpanded: false,
|
|
|
|
|
+ categoryBadgeCount: 0,
|
|
|
|
|
+ elementBadgeCount: 0,
|
|
|
|
|
+ rov_score: element.rov_score,
|
|
|
|
|
+ title: element.element_name ?? element.element_id,
|
|
|
|
|
+ };
|
|
|
|
|
+ nodeMap.set(layoutNode.key, layoutNode);
|
|
|
|
|
+ return layoutNode;
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const buildVisibleTree = (
|
|
|
|
|
+ category: CategoryNode,
|
|
|
|
|
+ parentKey: string | null,
|
|
|
|
|
+ ): void => {
|
|
|
|
|
+ const layoutDepth = getLayoutDepth(category, categoryDepthMap);
|
|
|
|
|
+ const childCategories = childrenMap.get(category.category_id) ?? [];
|
|
|
|
|
+ const allChildElements = elementsByCategory.get(category.category_id) ?? [];
|
|
|
|
|
+ const childElements = showInvalidNodes
|
|
|
|
|
+ ? allChildElements
|
|
|
|
|
+ : allChildElements.filter((item) => !isZeroRovScore(item.rov_score));
|
|
|
|
|
+ const visibleChildCategories = showInvalidNodes
|
|
|
|
|
+ ? childCategories
|
|
|
|
|
+ : childCategories.filter((item) => !isZeroRovScore(item.rov_score));
|
|
|
|
|
+
|
|
|
|
|
+ const hasCategoryChildren = childCategories.length > 0;
|
|
|
|
|
+ const hasElements = childElements.length > 0;
|
|
|
|
|
+ const categoryExpanded = expandedCategoryIds.has(category.category_id);
|
|
|
|
|
+ const elementsExpanded = expandedElementParents.has(category.category_id);
|
|
|
|
|
+ const showSelf = showInvalidNodes || !isZeroRovScore(category.rov_score);
|
|
|
|
|
+ const passThrough = !showSelf;
|
|
|
|
|
+ const traverseCategories = passThrough || categoryExpanded;
|
|
|
|
|
+
|
|
|
|
|
+ const categoryChildKeys =
|
|
|
|
|
+ traverseCategories && hasCategoryChildren
|
|
|
|
|
+ ? childCategories.map((child) => `category:${child.category_id}`)
|
|
|
|
|
+ : [];
|
|
|
|
|
+ const shouldShowElements =
|
|
|
|
|
+ hasElements && (elementsExpanded || (passThrough && childElements.length > 0));
|
|
|
|
|
+ const elementChildKeys = shouldShowElements
|
|
|
|
|
+ ? childElements.map((element) => `element:${element.element_id}`)
|
|
|
|
|
+ : [];
|
|
|
|
|
+
|
|
|
|
|
+ let linkParentKey = parentKey;
|
|
|
|
|
+
|
|
|
|
|
+ if (showSelf) {
|
|
|
|
|
+ const node = buildCategoryNode(category, layoutDepth, parentKey, {
|
|
|
|
|
+ categoryChildKeys,
|
|
|
|
|
+ elementChildKeys,
|
|
|
|
|
+ hasCategoryChildren,
|
|
|
|
|
+ hasElements,
|
|
|
|
|
+ categoryExpanded,
|
|
|
|
|
+ elementsExpanded,
|
|
|
|
|
+ categoryBadgeCount: visibleChildCategories.length,
|
|
|
|
|
+ elementBadgeCount: childElements.length,
|
|
|
|
|
+ });
|
|
|
|
|
+ linkParentKey = node.key;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (traverseCategories) {
|
|
|
|
|
+ for (const childKey of categoryChildKeys) {
|
|
|
|
|
+ const childId = childKey.slice("category:".length);
|
|
|
|
|
+ const childCategory = data.categories.find((item) => item.category_id === childId);
|
|
|
|
|
+ if (childCategory) {
|
|
|
|
|
+ buildVisibleTree(childCategory, linkParentKey);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (elementChildKeys.length > 0 && linkParentKey) {
|
|
|
|
|
+ const parentLayoutDepth = getLayoutDepth(category, categoryDepthMap);
|
|
|
|
|
+ const elementLayoutDepth =
|
|
|
|
|
+ categoryChildKeys.length > 0
|
|
|
|
|
+ ? Math.min(
|
|
|
|
|
+ ...categoryChildKeys.map((childKey) => {
|
|
|
|
|
+ const childId = childKey.slice("category:".length);
|
|
|
|
|
+ const childCategory = data.categories.find(
|
|
|
|
|
+ (item) => item.category_id === childId,
|
|
|
|
|
+ );
|
|
|
|
|
+ return childCategory
|
|
|
|
|
+ ? getLayoutDepth(childCategory, categoryDepthMap)
|
|
|
|
|
+ : parentLayoutDepth + 1;
|
|
|
|
|
+ }),
|
|
|
|
|
+ )
|
|
|
|
|
+ : parentLayoutDepth + 1;
|
|
|
|
|
+ for (const childKey of elementChildKeys) {
|
|
|
|
|
+ const childId = childKey.slice("element:".length);
|
|
|
|
|
+ const childElement = data.elements.find((item) => item.element_id === childId);
|
|
|
|
|
+ if (childElement) {
|
|
|
|
|
+ buildElementNode(childElement, elementLayoutDepth, linkParentKey);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ for (const root of roots) {
|
|
|
|
|
+ buildVisibleTree(root, null);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ for (const detached of detachedCategories) {
|
|
|
|
|
+ if (!isCategoryVisibleByExpandFilter(detached, categoryDepthMap, expandLevelFilter)) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ buildVisibleTree(detached, null);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ for (const node of nodeMap.values()) {
|
|
|
|
|
+ node.childKeys = [];
|
|
|
|
|
+ node.categoryChildKeys = [];
|
|
|
|
|
+ node.elementChildKeys = [];
|
|
|
|
|
+ }
|
|
|
|
|
+ for (const node of nodeMap.values()) {
|
|
|
|
|
+ if (!node.parentKey) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ const parent = nodeMap.get(node.parentKey);
|
|
|
|
|
+ if (!parent) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ parent.childKeys.push(node.key);
|
|
|
|
|
+ if (node.kind === "category") {
|
|
|
|
|
+ parent.categoryChildKeys.push(node.key);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ parent.elementChildKeys.push(node.key);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ for (const node of nodeMap.values()) {
|
|
|
|
|
+ if (node.categoryChildKeys.length > 0 || node.elementChildKeys.length > 0) {
|
|
|
|
|
+ node.childKeys = [...node.categoryChildKeys, ...node.elementChildKeys];
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const assignYPositions = (nodeKey: string, startY: number): number => {
|
|
|
|
|
+ const node = nodeMap.get(nodeKey);
|
|
|
|
|
+ if (!node) {
|
|
|
|
|
+ return startY;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (node.childKeys.length === 0) {
|
|
|
|
|
+ node.y = startY;
|
|
|
|
|
+ return startY + NODE_HEIGHT + ROW_GAP;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let cursor = startY;
|
|
|
|
|
+ for (const childKey of node.childKeys) {
|
|
|
|
|
+ cursor = assignYPositions(childKey, cursor);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const firstChild = nodeMap.get(node.childKeys[0]);
|
|
|
|
|
+ const lastChild = nodeMap.get(node.childKeys[node.childKeys.length - 1]);
|
|
|
|
|
+ if (firstChild && lastChild) {
|
|
|
|
|
+ if (node.categoryChildKeys.length > 0 && node.elementChildKeys.length > 0) {
|
|
|
|
|
+ const firstCategoryChild = nodeMap.get(node.categoryChildKeys[0]);
|
|
|
|
|
+ const lastCategoryChild = nodeMap.get(
|
|
|
|
|
+ node.categoryChildKeys[node.categoryChildKeys.length - 1],
|
|
|
|
|
+ );
|
|
|
|
|
+ if (firstCategoryChild && lastCategoryChild) {
|
|
|
|
|
+ node.y = (firstCategoryChild.y + lastCategoryChild.y) / 2;
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ node.y = (firstChild.y + lastChild.y) / 2;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return cursor;
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const detachedIds = new Set(detachedCategories.map((item) => item.category_id));
|
|
|
|
|
+
|
|
|
|
|
+ let yCursor = CANVAS_PADDING;
|
|
|
|
|
+ const connectedTopLevelKeys = [...nodeMap.values()]
|
|
|
|
|
+ .filter((node) => node.parentKey === null && !detachedIds.has(node.category?.category_id ?? ""))
|
|
|
|
|
+ .sort((a, b) => a.depth - b.depth || a.title.localeCompare(b.title, "zh-CN"));
|
|
|
|
|
+ for (const node of connectedTopLevelKeys) {
|
|
|
|
|
+ yCursor = assignYPositions(node.key, yCursor);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const refreshMaxBottomByDepth = () => {
|
|
|
|
|
+ const maxBottomByDepth = new Map<number, number>();
|
|
|
|
|
+ for (const node of nodeMap.values()) {
|
|
|
|
|
+ const bottom = node.y + NODE_HEIGHT;
|
|
|
|
|
+ maxBottomByDepth.set(node.depth, Math.max(maxBottomByDepth.get(node.depth) ?? 0, bottom));
|
|
|
|
|
+ }
|
|
|
|
|
+ return maxBottomByDepth;
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ let maxBottomByDepth = refreshMaxBottomByDepth();
|
|
|
|
|
+
|
|
|
|
|
+ const detachedTopLevelKeys = [...nodeMap.values()]
|
|
|
|
|
+ .filter(
|
|
|
|
|
+ (node) =>
|
|
|
|
|
+ node.parentKey === null &&
|
|
|
|
|
+ node.kind === "category" &&
|
|
|
|
|
+ detachedIds.has(node.category?.category_id ?? ""),
|
|
|
|
|
+ )
|
|
|
|
|
+ .sort((a, b) => a.depth - b.depth || a.title.localeCompare(b.title, "zh-CN"));
|
|
|
|
|
+
|
|
|
|
|
+ for (const node of detachedTopLevelKeys) {
|
|
|
|
|
+ const columnBottom = maxBottomByDepth.get(node.depth);
|
|
|
|
|
+ const startY =
|
|
|
|
|
+ columnBottom !== undefined
|
|
|
|
|
+ ? columnBottom + ROW_GAP
|
|
|
|
|
+ : CANVAS_PADDING;
|
|
|
|
|
+ yCursor = assignYPositions(node.key, startY);
|
|
|
|
|
+ maxBottomByDepth = refreshMaxBottomByDepth();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const layoutNodes = [...nodeMap.values()];
|
|
|
|
|
+ const connectors = layoutNodes.flatMap((node) =>
|
|
|
|
|
+ node.childKeys
|
|
|
|
|
+ .map((childKey) => {
|
|
|
|
|
+ const child = nodeMap.get(childKey);
|
|
|
|
|
+ if (!child) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ return buildConnectorPath(node, child);
|
|
|
|
|
+ })
|
|
|
|
|
+ .filter((path): path is string => Boolean(path)),
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ const maxDepth = layoutNodes.reduce((max, node) => Math.max(max, node.depth), 0);
|
|
|
|
|
+ const maxY = layoutNodes.reduce((max, node) => Math.max(max, node.y + NODE_HEIGHT), 0);
|
|
|
|
|
+
|
|
|
|
|
+ const levelHeaders: LevelHeader[] = [];
|
|
|
|
|
+ for (let depth = 0; depth <= maxDepth; depth += 1) {
|
|
|
|
|
+ const nodesAtDepth = layoutNodes.filter((node) => node.depth === depth);
|
|
|
|
|
+ if (nodesAtDepth.length === 0) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ const categoryNodes = nodesAtDepth.filter((node) => node.kind === "category");
|
|
|
|
|
+ const elementOnly = categoryNodes.length === 0;
|
|
|
|
|
+ let label = "元素";
|
|
|
|
|
+ if (!elementOnly) {
|
|
|
|
|
+ label = `L${depth + 1}`;
|
|
|
|
|
+ }
|
|
|
|
|
+ levelHeaders.push({
|
|
|
|
|
+ depth,
|
|
|
|
|
+ label,
|
|
|
|
|
+ x: depth * (NODE_WIDTH + COLUMN_GAP) + CANVAS_PADDING,
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ layoutNodes,
|
|
|
|
|
+ connectors,
|
|
|
|
|
+ levelHeaders,
|
|
|
|
|
+ canvasWidth: (maxDepth + 1) * (NODE_WIDTH + COLUMN_GAP) + CANVAS_PADDING * 2,
|
|
|
|
|
+ canvasHeight: Math.max(420, maxY + CANVAS_PADDING),
|
|
|
|
|
+ };
|
|
|
|
|
+ }, [data, roots, detachedCategories, childrenMap, elementsByCategory, expandedCategoryIds, expandedElementParents, showInvalidNodes, expandLevelFilter, categoryDepthMap]);
|
|
|
|
|
+
|
|
|
|
|
+ const fitToView = useCallback(() => {
|
|
|
|
|
+ const viewport = viewportRef.current;
|
|
|
|
|
+ if (!viewport || canvasWidth <= 0 || canvasHeight <= 0) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ const padding = 32;
|
|
|
|
|
+ const scaleX = (viewport.clientWidth - padding) / canvasWidth;
|
|
|
|
|
+ const scaleY = (viewport.clientHeight - padding) / canvasHeight;
|
|
|
|
|
+ const nextZoom = Math.min(1, scaleX, scaleY);
|
|
|
|
|
+ setZoom(Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, nextZoom)));
|
|
|
|
|
+ }, [canvasWidth, canvasHeight]);
|
|
|
|
|
+
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ const viewport = viewportRef.current;
|
|
|
|
|
+ if (!viewport) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ const onWheel = (event: WheelEvent) => {
|
|
|
|
|
+ if (!event.ctrlKey && !event.metaKey) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ event.preventDefault();
|
|
|
|
|
+ const delta = event.deltaY > 0 ? -0.06 : 0.06;
|
|
|
|
|
+ setZoom((value) => Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, value + delta)));
|
|
|
|
|
+ };
|
|
|
|
|
+ viewport.addEventListener("wheel", onWheel, { passive: false });
|
|
|
|
|
+ return () => viewport.removeEventListener("wheel", onWheel);
|
|
|
|
|
+ }, []);
|
|
|
|
|
+
|
|
|
|
|
+ const handleCanvasScroll = useCallback(() => {
|
|
|
|
|
+ const viewport = viewportRef.current;
|
|
|
|
|
+ if (!viewport) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ setCanvasScrollLeft(viewport.scrollLeft);
|
|
|
|
|
+ }, []);
|
|
|
|
|
+
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ const viewport = viewportRef.current;
|
|
|
|
|
+ if (!viewport) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ handleCanvasScroll();
|
|
|
|
|
+ viewport.addEventListener("scroll", handleCanvasScroll, { passive: true });
|
|
|
|
|
+ return () => viewport.removeEventListener("scroll", handleCanvasScroll);
|
|
|
|
|
+ }, [handleCanvasScroll, layoutNodes.length, zoom, canvasWidth]);
|
|
|
|
|
+
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ document.body.classList.toggle("cet-tree-fullscreen-active", treeFullscreen);
|
|
|
|
|
+ return () => {
|
|
|
|
|
+ document.body.classList.remove("cet-tree-fullscreen-active");
|
|
|
|
|
+ };
|
|
|
|
|
+ }, [treeFullscreen]);
|
|
|
|
|
+
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ if (!treeFullscreen) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ const onKeyDown = (event: KeyboardEvent) => {
|
|
|
|
|
+ if (event.key === "Escape") {
|
|
|
|
|
+ setTreeFullscreen(false);
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+ window.addEventListener("keydown", onKeyDown);
|
|
|
|
|
+ return () => window.removeEventListener("keydown", onKeyDown);
|
|
|
|
|
+ }, [treeFullscreen]);
|
|
|
|
|
+
|
|
|
|
|
+ const handleToggleTreeFullscreen = () => {
|
|
|
|
|
+ setTreeFullscreen((value) => !value);
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const handleToggleCategory = (node: LayoutNode) => {
|
|
|
|
|
+ setActiveKey(node.key);
|
|
|
|
|
+ const category = node.category;
|
|
|
|
|
+ if (!category || !node.hasCategoryChildren) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const descendantIds = collectDescendantCategoryIds(
|
|
|
|
|
+ category.category_id,
|
|
|
|
|
+ childrenMap,
|
|
|
|
|
+ );
|
|
|
|
|
+ const willCollapse = expandedCategoryIds.has(category.category_id);
|
|
|
|
|
+
|
|
|
|
|
+ setExpandedCategoryIds((prev) => {
|
|
|
|
|
+ const next = new Set(prev);
|
|
|
|
|
+ if (willCollapse) {
|
|
|
|
|
+ next.delete(category.category_id);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ next.add(category.category_id);
|
|
|
|
|
+ }
|
|
|
|
|
+ return next;
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ if (willCollapse) {
|
|
|
|
|
+ setExpandedElementParents((prev) => {
|
|
|
|
|
+ const next = new Set(prev);
|
|
|
|
|
+ for (const id of descendantIds) {
|
|
|
|
|
+ next.delete(id);
|
|
|
|
|
+ }
|
|
|
|
|
+ return next;
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const handleToggleElements = (node: LayoutNode) => {
|
|
|
|
|
+ setActiveKey(node.key);
|
|
|
|
|
+ const category = node.category;
|
|
|
|
|
+ if (!category || !node.hasElements) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ setExpandedElementParents((prev) => {
|
|
|
|
|
+ const next = new Set(prev);
|
|
|
|
|
+ if (next.has(category.category_id)) {
|
|
|
|
|
+ next.delete(category.category_id);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ next.add(category.category_id);
|
|
|
|
|
+ }
|
|
|
|
|
+ return next;
|
|
|
|
|
+ });
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const handleCollapseAll = () => {
|
|
|
|
|
+ setExpandedCategoryIds(new Set());
|
|
|
|
|
+ setExpandedElementParents(new Set());
|
|
|
|
|
+ setActiveKey(null);
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const handleCollapseElements = () => {
|
|
|
|
|
+ setExpandedElementParents(new Set());
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const disabledDate = (current: Dayjs) => {
|
|
|
|
|
+ const available = new Set(data?.available_dates ?? []);
|
|
|
|
|
+ if (available.size === 0) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ return !available.has(current.format("YYYYMMDD"));
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const categoryRovScalesByLevel = useMemo(
|
|
|
|
|
+ () => buildCategoryRovScalesByLevel(data?.categories ?? [], categoryDepthMap),
|
|
|
|
|
+ [data?.categories, categoryDepthMap],
|
|
|
|
|
+ );
|
|
|
|
|
+ const elementRovScale = useMemo(
|
|
|
|
|
+ () => buildElementRovScale(data?.elements ?? []),
|
|
|
|
|
+ [data?.elements],
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ const invalidNodeCount = useMemo(() => {
|
|
|
|
|
+ if (!data) {
|
|
|
|
|
+ return 0;
|
|
|
|
|
+ }
|
|
|
|
|
+ const invalidCategories = data.categories.filter((item) => isZeroRovScore(item.rov_score)).length;
|
|
|
|
|
+ const invalidElements = data.elements.filter((item) => isZeroRovScore(item.rov_score)).length;
|
|
|
|
|
+ return invalidCategories + invalidElements;
|
|
|
|
|
+ }, [data]);
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className={`cet-app${treeFullscreen ? " cet-app--tree-fullscreen" : ""}`}>
|
|
|
|
|
+ <header className="cet-header">
|
|
|
|
|
+ <div className="cet-header-main">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <p className="cet-eyebrow">Demand Summary</p>
|
|
|
|
|
+ <h1 className="cet-title">需求汇总</h1>
|
|
|
|
|
+ <p className="cet-subtitle">
|
|
|
|
|
+ 从左到右浏览 L1/L2/L3/... 分类层级;任意有元素的分类可点「N 元素」展开。
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div className="cet-toolbar">
|
|
|
|
|
+ <div className="cet-toolbar-group">
|
|
|
|
|
+ <span className="cet-toolbar-label">效果日期</span>
|
|
|
|
|
+ <DatePicker
|
|
|
|
|
+ value={selectedDate}
|
|
|
|
|
+ onChange={(value) => setSelectedDate(value)}
|
|
|
|
|
+ allowClear={false}
|
|
|
|
|
+ disabledDate={disabledDate}
|
|
|
|
|
+ format="YYYY-MM-DD"
|
|
|
|
|
+ />
|
|
|
|
|
+ <Button
|
|
|
|
|
+ type="primary"
|
|
|
|
|
+ icon={<ReloadOutlined />}
|
|
|
|
|
+ loading={loading}
|
|
|
|
|
+ onClick={() => void fetchTree(selectedDate?.format("YYYYMMDD"))}
|
|
|
|
|
+ >
|
|
|
|
|
+ 查询
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div className="cet-toolbar-group">
|
|
|
|
|
+ <span className="cet-toolbar-label">展开层级</span>
|
|
|
|
|
+ <Select<ExpandLevelFilter>
|
|
|
|
|
+ value={expandLevelFilter}
|
|
|
|
|
+ onChange={handleExpandLevelFilterChange}
|
|
|
|
|
+ disabled={!data}
|
|
|
|
|
+ style={{ width: 108 }}
|
|
|
|
|
+ options={expandLevelOptions}
|
|
|
|
|
+ />
|
|
|
|
|
+ <Button icon={<ShrinkOutlined />} onClick={handleCollapseAll} disabled={!data}>
|
|
|
|
|
+ 全部收起
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Button onClick={handleCollapseElements} disabled={!data || expandedElementParents.size === 0}>
|
|
|
|
|
+ 收起元素
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div className="cet-toolbar-group cet-filter-group">
|
|
|
|
|
+ <Checkbox
|
|
|
|
|
+ checked={showInvalidNodes}
|
|
|
|
|
+ onChange={(event) => setShowInvalidNodes(event.target.checked)}
|
|
|
|
|
+ disabled={!data}
|
|
|
|
|
+ >
|
|
|
|
|
+ 展示无效节点
|
|
|
|
|
+ </Checkbox>
|
|
|
|
|
+ {!showInvalidNodes && invalidNodeCount > 0 ? (
|
|
|
|
|
+ <span className="cet-filter-hint">已隐藏 {invalidNodeCount} 个</span>
|
|
|
|
|
+ ) : null}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div className="cet-legend cet-legend--scale">
|
|
|
|
|
+ <div className="cet-legend-bar" aria-label="ROV 色阶" />
|
|
|
|
|
+ <span className="cet-legend-tag cet-legend-tag--bad">差</span>
|
|
|
|
|
+ <span className="cet-legend-tag">正常</span>
|
|
|
|
|
+ <span className="cet-legend-tag cet-legend-tag--good">好</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="cet-legend cet-legend--zero">
|
|
|
|
|
+ <span className="cet-legend-swatch cet-legend-swatch--zero" aria-hidden />
|
|
|
|
|
+ <span className="cet-legend-label">无效节点</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {appliedDt ? (
|
|
|
|
|
+ <div className="cet-stats">
|
|
|
|
|
+ <span>日期 {formatDtLabel(appliedDt)}</span>
|
|
|
|
|
+ <span>分类 {data?.categories.length ?? 0}</span>
|
|
|
|
|
+ <span>元素 {data?.elements.length ?? 0}</span>
|
|
|
|
|
+ <span>节点 {layoutNodes.length}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : null}
|
|
|
|
|
+ </header>
|
|
|
|
|
+
|
|
|
|
|
+ {error ? (
|
|
|
|
|
+ <div className="cet-alert-wrap">
|
|
|
|
|
+ <Alert type="error" showIcon message={`请求失败: ${error}`} />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : null}
|
|
|
|
|
+
|
|
|
|
|
+ <main className="cet-stage">
|
|
|
|
|
+ <Spin spinning={loading} tip="加载中..." wrapperClassName="cet-stage-spin">
|
|
|
|
|
+ {layoutNodes.length === 0 && !loading ? (
|
|
|
|
|
+ <div className="cet-empty">暂无分类数据,请先同步垂直领域分类。</div>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <div className="cet-tree-viewport">
|
|
|
|
|
+ <div className="cet-viewport-controls">
|
|
|
|
|
+ <Button
|
|
|
|
|
+ icon={<ZoomOutOutlined />}
|
|
|
|
|
+ onClick={() => setZoom((value) => Math.max(MIN_ZOOM, value - 0.1))}
|
|
|
|
|
+ disabled={!data}
|
|
|
|
|
+ aria-label="缩小"
|
|
|
|
|
+ />
|
|
|
|
|
+ <span className="cet-zoom-value">{Math.round(zoom * 100)}%</span>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ icon={<ZoomInOutlined />}
|
|
|
|
|
+ onClick={() => setZoom((value) => Math.min(MAX_ZOOM, value + 0.1))}
|
|
|
|
|
+ disabled={!data}
|
|
|
|
|
+ aria-label="放大"
|
|
|
|
|
+ />
|
|
|
|
|
+ <Button icon={<CompressOutlined />} onClick={fitToView} disabled={!data}>
|
|
|
|
|
+ 全局视角
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Tooltip title={treeFullscreen ? "退出全屏(Esc)" : "全屏展示树"}>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ icon={treeFullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
|
|
|
|
|
+ onClick={handleToggleTreeFullscreen}
|
|
|
|
|
+ disabled={!data}
|
|
|
|
|
+ aria-label={treeFullscreen ? "退出全屏" : "全屏展示树"}
|
|
|
|
|
+ >
|
|
|
|
|
+ {treeFullscreen ? "退出全屏" : "全屏"}
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </Tooltip>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div
|
|
|
|
|
+ className="cet-level-headers-bar"
|
|
|
|
|
+ style={{ height: LEVEL_HEADER_HEIGHT * zoom }}
|
|
|
|
|
+ >
|
|
|
|
|
+ <div
|
|
|
|
|
+ className="cet-level-headers-track"
|
|
|
|
|
+ style={{
|
|
|
|
|
+ width: canvasWidth * zoom,
|
|
|
|
|
+ transform: `translateX(-${canvasScrollLeft}px)`,
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ <div
|
|
|
|
|
+ className="cet-level-headers"
|
|
|
|
|
+ style={{
|
|
|
|
|
+ width: canvasWidth,
|
|
|
|
|
+ height: LEVEL_HEADER_HEIGHT,
|
|
|
|
|
+ transform: `scale(${zoom})`,
|
|
|
|
|
+ transformOrigin: "top left",
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ {levelHeaders.map((header) => (
|
|
|
|
|
+ <div
|
|
|
|
|
+ key={`level-${header.depth}-${header.label}`}
|
|
|
|
|
+ className="cet-level-header"
|
|
|
|
|
+ style={{ left: header.x, width: NODE_WIDTH }}
|
|
|
|
|
+ >
|
|
|
|
|
+ {header.label}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div className="cet-canvas-wrap" ref={viewportRef}>
|
|
|
|
|
+ <div
|
|
|
|
|
+ className="cet-canvas-scaler"
|
|
|
|
|
+ style={{
|
|
|
|
|
+ width: canvasWidth * zoom,
|
|
|
|
|
+ height: canvasHeight * zoom,
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ <div
|
|
|
|
|
+ className="cet-canvas"
|
|
|
|
|
+ style={{
|
|
|
|
|
+ width: canvasWidth,
|
|
|
|
|
+ height: canvasHeight,
|
|
|
|
|
+ transform: `scale(${zoom})`,
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ <svg
|
|
|
|
|
+ className="cet-lines"
|
|
|
|
|
+ width={canvasWidth}
|
|
|
|
|
+ height={canvasHeight}
|
|
|
|
|
+ aria-hidden
|
|
|
|
|
+ >
|
|
|
|
|
+ {connectors.map((path, index) => (
|
|
|
|
|
+ <path key={`${path}-${index}`} d={path} className="cet-line" />
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </svg>
|
|
|
|
|
+
|
|
|
|
|
+ <div className="cet-nodes">
|
|
|
|
|
+ {layoutNodes.map((node) => (
|
|
|
|
|
+ <TreeNodeCard
|
|
|
|
|
+ key={node.key}
|
|
|
|
|
+ node={node}
|
|
|
|
|
+ categoryRovScalesByLevel={categoryRovScalesByLevel}
|
|
|
|
|
+ elementRovScale={elementRovScale}
|
|
|
|
|
+ categoryDepthMap={categoryDepthMap}
|
|
|
|
|
+ active={activeKey === node.key}
|
|
|
|
|
+ onToggleCategory={() => handleToggleCategory(node)}
|
|
|
|
|
+ onToggleElements={() => handleToggleElements(node)}
|
|
|
|
|
+ />
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </Spin>
|
|
|
|
|
+ </main>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|