Files
Cloud-Tour-to-Libo/admin-web/src/panels/plaza/GraphCanvas.tsx
T

1735 lines
77 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState, useCallback } from "react";
import { Card, Input, Button, Space, Tag, Drawer, Descriptions, message, Spin, Row, Col, Statistic, Tooltip } from "antd";
import {
ReloadOutlined, NodeIndexOutlined, ApartmentOutlined,
FullscreenOutlined, FullscreenExitOutlined, AimOutlined, DownloadOutlined,
ThunderboltOutlined, LeftOutlined, RightOutlined,
} from "@ant-design/icons";
import * as echarts from "echarts";
import api, { displayGraphName, getProjectContext } from "../../api";
const CATEGORY_COLORS: Record<string, string> = {
Place: "#00D4AA", Area: "#5B8FF9", GeoCell: "#5B8FF9", 片区: "#5B8FF9", Station: "#F6BD16",
BusStop: "#F6BD16", MetroStation: "#1677FF", TransitStop: "#F6BD16",
BusLine: "#9270CA",
地点: "#00D4AA", 景点: "#1677FF", 景区: "#1677FF", 美食: "#FF5A1F", 酒店: "#722ED1",
交通设施: "#13A8A8", 商场: "#F6BD16", 公交站: "#F6BD16", 地铁站: "#1677FF", 交通站点: "#F6BD16",
RouteTemplate: "#E8684A", ExperienceTag: "#6DC8EC",
Source: "#9270CA", Evidence: "#FF99C3",
TourProduct: "#4C6FFF", ProductDay: "#6DC8EC", RouteStop: "#F6BD16",
ScenicArea: "#D96C75", ScenicAttraction: "#F6D45B",
Hotel: "#E8684A", Restaurant: "#5D7092",
AdministrativeRegion: "#6ED6D3", HotelResource: "#E8684A", RestaurantResource: "#5D7092",
ResourceSlot: "#9270CA", TicketFee: "#FF9D4D", FeeItem: "#FFB84D",
VehicleResource: "#269A99", ResourceLibrary: "#7B61FF", GeoPoint: "#9FD3FF",
SubAttraction: "#F7C948", TravelItem: "#4C9AFF", ItineraryResourceBinding: "#8E6AD8",
CustomerSelection: "#FF99C3",
};
// Fallback palette — categories not in CATEGORY_COLORS get a stable auto color
const PALETTE = [
"#5B8FF9", "#00D4AA", "#F6BD16", "#E8684A", "#6DC8EC", "#9270CA",
"#FF99C3", "#269A99", "#FF9D4D", "#5D7092", "#FF6B3B", "#37A2DA",
"#67E0E3", "#9FE6B8", "#FFDB5C", "#E062AE", "#96BFFF",
];
const _colorCache: Record<string, string> = {};
function colorOf(group: string): string {
if (CATEGORY_COLORS[group]) return CATEGORY_COLORS[group];
if (!_colorCache[group]) {
_colorCache[group] = PALETTE[Object.keys(_colorCache).length % PALETTE.length];
}
return _colorCache[group];
}
type GNode = { id: string; label?: string; name?: string; group: string; properties?: Record<string, any> };
type GEdge = { id: string; from: string; to: string; label: string; properties?: Record<string, any> };
type DetailItem = { key: string; label: string; value: any; renderAs?: "tag" };
const PRIORITY_LABEL_GROUPS = new Set([
"ScenicAttraction",
]);
const TRAVEL_LAYOUT_ORDER = [
"TourProduct",
"ProductPricePlan",
"TourPriceEvent",
"ProductDay",
"RouteStop",
"ScenicArea",
"ScenicAttraction",
"SubAttraction",
"Hotel",
"Restaurant",
"TravelItem",
"HotelRoomType",
"RoomPriceEvent",
"ResourcePriceRule",
"BusinessRule",
"AdministrativeRegion",
];
const TRAVEL_LAYOUT_GROUPS = new Set(TRAVEL_LAYOUT_ORDER);
function numberProp(props: Record<string, any> = {}, keys: string[]): number {
for (const key of keys) {
const value = Number(props[key]);
if (Number.isFinite(value)) return value;
}
return Number.POSITIVE_INFINITY;
}
function layoutName(node: GNode): string {
const props = node.properties || {};
return String(props.route_line_no || props.day_no || props.order_no || props.global_stop_order || props.name || node.label || node.name || node.id);
}
function compareTravelNodes(a: GNode, b: GNode): number {
const ap = a.properties || {};
const bp = b.properties || {};
const numericKeys = [
"route_line_no",
"day_no",
"global_stop_order",
"order_no",
"day_stop_order",
"rank",
"departure_date",
"price_date",
];
for (const key of numericKeys) {
const av = Number(ap[key]);
const bv = Number(bp[key]);
if (Number.isFinite(av) || Number.isFinite(bv)) {
const diff = (Number.isFinite(av) ? av : Number.POSITIVE_INFINITY) - (Number.isFinite(bv) ? bv : Number.POSITIVE_INFINITY);
if (diff) return diff;
}
}
return layoutName(a).localeCompare(layoutName(b), "zh-Hans-CN");
}
function isTravelBusinessNode(node: GNode): boolean {
const props = node.properties || {};
return (
TRAVEL_LAYOUT_GROUPS.has(node.group) ||
isUsefulValue(props.product_id) ||
isUsefulValue(props.route_line_id) ||
isUsefulValue(props.route_line_no) ||
isUsefulValue(props.attraction_id) ||
isUsefulValue(props.hotel_id) ||
isUsefulValue(props.restaurant_id)
);
}
function buildTravelLayeredLayout(nodes: GNode[]): Map<string, { x: number; y: number }> {
const groups = new Map<string, GNode[]>();
nodes.forEach((node) => {
const key = TRAVEL_LAYOUT_GROUPS.has(node.group) ? node.group : `__other__${node.group}`;
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(node);
});
const presentGroups = [...groups.keys()].sort((a, b) => {
const ai = TRAVEL_LAYOUT_ORDER.indexOf(a);
const bi = TRAVEL_LAYOUT_ORDER.indexOf(b);
const av = ai >= 0 ? ai : TRAVEL_LAYOUT_ORDER.length + 1;
const bv = bi >= 0 ? bi : TRAVEL_LAYOUT_ORDER.length + 1;
return av - bv || a.localeCompare(b, "zh-Hans-CN");
});
const positions = new Map<string, { x: number; y: number }>();
const columnGap = 260;
const startX = -((presentGroups.length - 1) * columnGap) / 2;
presentGroups.forEach((group, colIndex) => {
const items = [...(groups.get(group) || [])].sort(compareTravelNodes);
const baseX = startX + colIndex * columnGap;
if (!items.length) return;
if (items.length <= 42) {
const yGap = items.length <= 18 ? 62 : items.length <= 30 ? 46 : 34;
const startY = -((items.length - 1) * yGap) / 2;
items.forEach((node, row) => {
positions.set(node.id, { x: baseX, y: startY + row * yGap });
});
return;
}
// Dense columns, such as daily price events, are rendered as a compact grid
// inside their business layer so the whole graph remains visible.
const rows = Math.min(42, Math.ceil(Math.sqrt(items.length * 1.8)));
const lanes = Math.ceil(items.length / rows);
const xGap = 74;
const yGap = 34;
const gridStartX = baseX - ((lanes - 1) * xGap) / 2;
const gridStartY = -((rows - 1) * yGap) / 2;
items.forEach((node, index) => {
const lane = Math.floor(index / rows);
const row = index % rows;
positions.set(node.id, {
x: gridStartX + lane * xGap,
y: gridStartY + row * yGap,
});
});
});
// Slightly stabilize route skeleton rows when product/day/stop numbers exist.
const routeNodes = nodes
.filter((node) => ["TourProduct", "ProductDay", "RouteStop"].includes(node.group))
.sort(compareTravelNodes);
const routeLineIndexes = new Map<string, number>();
routeNodes.forEach((node) => {
const props = node.properties || {};
const routeKey = String(props.route_line_no || props.route_line_id || props.product_id || "");
if (routeKey && !routeLineIndexes.has(routeKey)) routeLineIndexes.set(routeKey, routeLineIndexes.size);
});
routeNodes.forEach((node) => {
const props = node.properties || {};
const routeKey = String(props.route_line_no || props.route_line_id || props.product_id || "");
const lineIndex = routeKey ? routeLineIndexes.get(routeKey) : undefined;
const dayNo = numberProp(props, ["day_no", "day_index"]);
const stopNo = numberProp(props, ["global_stop_order", "order_no", "day_stop_order"]);
const current = positions.get(node.id);
if (!current || lineIndex === undefined) return;
const baseY = -420 + lineIndex * 56;
const offset = node.group === "TourProduct"
? 0
: node.group === "ProductDay"
? (Number.isFinite(dayNo) ? Math.min(dayNo, 12) * 4 : 0)
: (Number.isFinite(stopNo) ? Math.min(stopNo, 32) * 3 : 0);
positions.set(node.id, { x: current.x, y: baseY + offset });
});
return positions;
}
const NORMAL_HEIGHT = 580;
const FULL_GRAPH_LIMIT = 2500;
const FULL_GRAPH_BATCH_SIZE = 1000;
const FULL_GRAPH_BASE_CYPHER = "MATCH (n)-[r]->(m) RETURN n,r,m";
const GENERIC_DEFAULT_CYPHER = `${FULL_GRAPH_BASE_CYPHER} LIMIT ${FULL_GRAPH_LIMIT}`;
const LIBO_DEFAULT_CYPHER = [
"MATCH (n:Hotel)-[r:IN_H3_R9]->(cell:GeoCell)-[areaRel:LOCATED_IN]->(area:Area) RETURN n,r,cell,areaRel,area,null AS parentRel,null AS parent LIMIT 100",
"UNION",
"MATCH (n:FoodPlace)-[r:IN_H3_R9]->(cell:GeoCell)-[areaRel:LOCATED_IN]->(area:Area) RETURN n,r,cell,areaRel,area,null AS parentRel,null AS parent LIMIT 100",
"UNION",
"MATCH (n:TransitFacility)-[r:IN_H3_R9]->(cell:GeoCell)-[areaRel:LOCATED_IN]->(area:Area) RETURN n,r,cell,areaRel,area,null AS parentRel,null AS parent LIMIT 60",
"UNION",
"MATCH (n:ScenicSpot)-[parentRel:PART_OF_SCENIC_AREA]->(parent) MATCH (n)-[r:IN_H3_R9]->(cell:GeoCell)-[areaRel:LOCATED_IN]->(area:Area) RETURN n,r,cell,areaRel,area,parentRel,parent LIMIT 50",
].join(" ");
const TRAVEL_CORE_CYPHER = [
"MATCH (p:TourProduct)-[lineStop:PRODUCT_HAS_ORDERED_STOP]->(s:RouteStop)",
"OPTIONAL MATCH (s)-[next:ROUTE_STOP_NEXT]->(s2:RouteStop)",
"OPTIONAL MATCH (s)-[visitArea:STOP_VISITS_SCENIC_AREA]->(sa:ScenicArea)",
"OPTIONAL MATCH (s)-[stopRegion:STOP_LOCATED_IN_REGION]->(region:AdministrativeRegion)",
"RETURN p,lineStop,s,next,s2,visitArea,sa,stopRegion,region LIMIT 700",
].join(" ");
const TRAVEL_OVERVIEW_CYPHER = [
"MATCH (region:AdministrativeRegion)<-[loc:LOCATED_IN_REGION]-(sa:ScenicArea)",
"OPTIONAL MATCH (sa)-[childRel:SCENIC_AREA_HAS_ATTRACTION]->(child:ScenicAttraction)",
"OPTIONAL MATCH (sa)-[near:NEARBY_LOCATION_RESOURCE]->(resource)",
"OPTIONAL MATCH (resource)-[resourceLoc:LOCATED_IN_REGION]->(resourceRegion:AdministrativeRegion)",
"OPTIONAL MATCH (product:TourProduct)-[cover:PRODUCT_COVERS_SCENIC_AREA]->(sa)",
"OPTIONAL MATCH (product)-[hasDay:HAS_DAY]->(day:ProductDay)-[dayCover:DAY_COVERS_SCENIC_AREA]->(sa)",
"OPTIONAL MATCH (day)-[hasStop:DAY_HAS_STOP]->(stop:RouteStop)-[stopVisit:STOP_VISITS_SCENIC_AREA]->(sa)",
"RETURN region,loc,sa,childRel,child,near,resource,resourceLoc,resourceRegion,cover,product,hasDay,day,dayCover,hasStop,stop,stopVisit LIMIT 180",
].join(" ");
const TRAVEL_DEFAULT_CYPHER = TRAVEL_CORE_CYPHER;
const TRAVEL_ITEM_ROUTE_SKELETON_CYPHER = [
"MATCH (p:TourProduct)-[pd:PRODUCT_HAS_DAY]->(d:ProductDay)-[ds:DAY_HAS_STOP]->(s:RouteStop)",
"OPTIONAL MATCH (s)-[next:ROUTE_STOP_NEXT]->(s2:RouteStop)",
"OPTIONAL MATCH (s)-[visit:STOP_VISITS_ATTRACTION]->(a:ScenicAttraction)",
"OPTIONAL MATCH (s)-[subRel:STOP_VISITS_SUB_ATTRACTION]->(sub:SubAttraction)",
"RETURN p,pd,d,ds,s,next,s2,visit,a,subRel,sub LIMIT 700",
].join(" ");
const TRAVEL_ITEM_CORE_CYPHER = [
"MATCH (p:TourProduct)-[pd:PRODUCT_HAS_DAY]->(d:ProductDay)-[ds:DAY_HAS_STOP]->(s:RouteStop)",
"OPTIONAL MATCH (s)-[next:ROUTE_STOP_NEXT]->(s2:RouteStop)",
"OPTIONAL MATCH (s)-[visit:STOP_VISITS_ATTRACTION]->(a:ScenicAttraction)",
"OPTIONAL MATCH (s)-[subRel:STOP_VISITS_SUB_ATTRACTION]->(sub:SubAttraction)",
"OPTIONAL MATCH (a)-[near:ATTRACTION_NEARBY_RESOURCE]->(poi)",
"WHERE poi IS NULL OR poi:Hotel OR poi:Restaurant",
"RETURN p,pd,d,ds,s,next,s2,visit,a,subRel,sub,near,poi LIMIT 900",
].join(" ");
const TRAVEL_ITEM_NEARBY_RESOURCE_CYPHER = [
"MATCH (a:ScenicAttraction)-[near:ATTRACTION_NEARBY_RESOURCE]->(poi)",
"WHERE poi:Hotel OR poi:Restaurant",
"RETURN a,near,poi ORDER BY a.name, near.resource_type, near.rank LIMIT 320",
].join(" ");
const TRAVEL_ITEM_ROUTE_NEARBY_RESOURCE_CYPHER = [
"MATCH (p:TourProduct)-[:PRODUCT_HAS_DAY]->(d:ProductDay)-[:DAY_HAS_STOP]->(s:RouteStop)-[visit:STOP_VISITS_ATTRACTION]->(a:ScenicAttraction)-[near:ATTRACTION_NEARBY_RESOURCE]->(poi)",
"WHERE poi:Hotel OR poi:Restaurant",
"RETURN p,d,s,visit,a,near,poi ORDER BY p.product_id, d.day_no, s.order_no, near.resource_type, near.rank LIMIT 520",
].join(" ");
const TRAVEL_ITEM_BINDING_CYPHER = [
"MATCH (p:TourProduct)-[:PRODUCT_HAS_DAY]->(d:ProductDay)-[:DAY_HAS_STOP]->(s:RouteStop)-[use:STOP_USES_DEFAULT_RESOURCE]->(i:TravelItem)",
"OPTIONAL MATCH (s)-[visit:STOP_VISITS_ATTRACTION]->(a:ScenicAttraction)",
"RETURN p,d,s,use,i,visit,a LIMIT 360",
].join(" ");
const TRAVEL_ITEM_REGION_RESOURCE_CYPHER = [
"MATCH (a:ScenicAttraction)-[has:ATTRACTION_HAS_ITEM]->(item:TravelItem)",
"OPTIONAL MATCH (s:RouteStop)-[visit:STOP_VISITS_ATTRACTION]->(a)",
"RETURN a,has,item,visit,s LIMIT 260",
].join(" ");
const TRAVEL_ITEM_FEE_CYPHER = [
"MATCH (a:ScenicAttraction)-[has:ATTRACTION_HAS_ITEM]->(i:TravelItem)",
"WHERE i.type IN ['Ticket','ScenicTransport','Insurance','Service']",
"OPTIONAL MATCH (s:RouteStop)-[visit:STOP_VISITS_ATTRACTION]->(a)",
"OPTIONAL MATCH (s)-[subRel:STOP_VISITS_SUB_ATTRACTION]->(sub:SubAttraction)",
"RETURN a,has,i,visit,s,subRel,sub LIMIT 260",
].join(" ");
const TRAVEL_ITEM_PRICE_CYPHER = [
"MATCH (p:TourProduct)-[pd:PRODUCT_HAS_DAY]->(d:ProductDay)-[ds:DAY_HAS_STOP]->(s:RouteStop)",
"OPTIONAL MATCH (s)-[use:STOP_USES_DEFAULT_RESOURCE]->(i:TravelItem)",
"OPTIONAL MATCH (s)-[visit:STOP_VISITS_ATTRACTION]->(a:ScenicAttraction)",
"RETURN p,pd,d,ds,s,use,i,visit,a LIMIT 420",
].join(" ");
const TRAVEL_ITEM_BUSINESS_SAMPLE_CYPHER = [
"MATCH (n)-[r]->(m)",
"WHERE NOT n:TourPriceEvent AND NOT m:TourPriceEvent",
"AND NOT n:RoomPriceEvent AND NOT m:RoomPriceEvent",
"AND NOT n:OntologySchema AND NOT m:OntologySchema",
"AND NOT n:OntologyEntityType AND NOT m:OntologyEntityType",
"AND NOT n:OntologyRelationType AND NOT m:OntologyRelationType",
"AND NOT n:OntologyField AND NOT m:OntologyField",
"RETURN n,r,m LIMIT 1200",
].join(" ");
const TRAVEL_ITEM_PRICE_PLAN_CYPHER = [
"MATCH (p:TourProduct)-[planRel:PRODUCT_HAS_PRICE_PLAN]->(plan:ProductPricePlan)",
"RETURN p,planRel,plan ORDER BY p.route_line_no, plan.plan_name LIMIT 320",
].join(" ");
const TRAVEL_ITEM_PRICE_EVENT_CYPHER = [
"MATCH (p:TourProduct)-[planRel:PRODUCT_HAS_PRICE_PLAN]->(plan:ProductPricePlan)-[eventRel:PRICE_PLAN_HAS_PRICE_EVENT]->(e:TourPriceEvent)",
"RETURN p,planRel,plan,eventRel,e ORDER BY p.route_line_no, plan.plan_name, e.departure_date LIMIT 360",
].join(" ");
const TRAVEL_REGION_RESOURCE_CYPHER =
"MATCH (region:AdministrativeRegion)<-[loc:LOCATED_IN_REGION]-(resource) WHERE resource:ScenicArea OR resource:HotelResource OR resource:RestaurantResource RETURN region,loc,resource LIMIT 180";
const TRAVEL_ROUTE_CYPHER = [
"MATCH (p:TourProduct)-[lineStop:PRODUCT_HAS_ORDERED_STOP]->(s:RouteStop)",
"OPTIONAL MATCH (s)-[next:ROUTE_STOP_NEXT]->(s2:RouteStop)",
"OPTIONAL MATCH (s)-[visitArea:STOP_VISITS_SCENIC_AREA]->(sa:ScenicArea)",
"OPTIONAL MATCH (s)-[visitAttr:STOP_VISITS_ATTRACTION]->(a:ScenicAttraction)",
"OPTIONAL MATCH (s)-[stopRegion:STOP_LOCATED_IN_REGION]->(region:AdministrativeRegion)",
"OPTIONAL MATCH (sa)-[areaRegion:LOCATED_IN_REGION]->(saRegion:AdministrativeRegion)",
"RETURN p,lineStop,s,next,s2,visitArea,sa,visitAttr,a,stopRegion,region,areaRegion,saRegion LIMIT 900",
].join(" ");
const TRAVEL_ROUTE_RESOURCE_CYPHER = [
"MATCH (p:TourProduct)-[lineStop:PRODUCT_HAS_ORDERED_STOP]->(s:RouteStop)-[visitArea:STOP_VISITS_SCENIC_AREA]->(sa:ScenicArea)",
"OPTIONAL MATCH (sa)-[near:NEARBY_LOCATION_RESOURCE]->(x)",
"OPTIONAL MATCH (x)-[xloc:LOCATED_IN_REGION]->(xRegion:AdministrativeRegion)",
"RETURN p,lineStop,s,visitArea,sa,near,x,xloc,xRegion ORDER BY near.drive_duration_min LIMIT 260",
].join(" ");
function isCypherQuery(q: string): boolean {
return /^(MATCH|RETURN|CALL)\b/i.test(q.trim());
}
function isFullGraphQuery(q: string): boolean {
return /^MATCH\s*\(n\)\s*-\s*\[r\]\s*->\s*\(m\)\s*RETURN\s+n\s*,\s*r\s*,\s*m(?:\s+LIMIT\s+\d+)?\s*$/i.test(q.trim());
}
function fullGraphQueryLimit(q: string): number {
const m = q.match(/\bLIMIT\s+(\d+)/i);
const explicit = m ? Number(m[1]) : FULL_GRAPH_LIMIT;
return Math.max(1, Math.min(FULL_GRAPH_LIMIT, explicit || FULL_GRAPH_LIMIT));
}
function isTravelItemGraphName(value?: any): boolean {
const name = String(value || "").toLowerCase();
return (
name.includes("travel_fixed_route_item") ||
name.includes("travel_agency_2_0") ||
name.includes("travel_agency_2.0") ||
name.includes("baixinghui") ||
name.includes("百姓惠") ||
name.includes("旅行社2.0") ||
name.includes("旅行社固定线路")
);
}
function isAnyTravelGraphName(value?: any): boolean {
const name = String(value || "").toLowerCase();
return name.includes("travel") || name.includes("旅行社") || name.includes("旅游");
}
function isYunyouLiboGraphName(value?: any): boolean {
const name = String(value || "").toLowerCase();
return name.includes("yunyou_libo") || name.includes("云游荔波");
}
function defaultCypherForGraph(value?: any): string {
return isYunyouLiboGraphName(value)
? LIBO_DEFAULT_CYPHER
: isTravelItemGraphName(value)
? TRAVEL_ITEM_CORE_CYPHER
: isAnyTravelGraphName(value)
? TRAVEL_DEFAULT_CYPHER
: GENERIC_DEFAULT_CYPHER;
}
function modeLabel(mode?: string): string {
if (mode === "llm_cypher") return "LLM 转 Cypher";
if (mode === "text_recall") return "文本召回兜底";
return "Cypher";
}
function splitBusLines(value?: any): string[] {
if (!value) return [];
return String(value)
.split(/[,,、]/)
.map((s) => s.trim())
.filter(Boolean);
}
function splitPhotoUrls(value?: any): string[] {
if (!value) return [];
return String(value)
.split(/[||,,\s]+/)
.map((s) => s.trim())
.filter((s) => /^https?:\/\//i.test(s));
}
const DETAIL_FIELD_LABELS: Record<string, string> = {
id: "知识ID",
event_id: "事件ID",
event_date: "发生时间",
evidence_quote: "证据原文",
confidence: "置信度",
review_status: "审核状态",
lat: "纬度",
lng: "经度",
address: "地址",
district: "行政区",
tel: "电话",
rating: "景区等级",
scenic_level: "景区等级",
level: "景区等级",
cost: "人均/参考价",
open_time: "营业时间",
business_area: "商圈",
source: "来源",
description: "简介",
summary: "摘要",
history: "历史",
features: "特色",
natural_key: "自然键",
product_id: "产品ID",
route_line_id: "线路ID",
route_line_no: "线路序号",
item_id: "资源ID",
binding_id: "绑定ID",
sub_attraction_id: "子景点ID",
scenic_area_id: "景区ID",
attraction_id: "景点ID",
type: "资源类型",
subtype: "资源子类",
status: "绑定状态",
binding_type: "槽位类型",
default_text: "默认安排",
extra_expense: "额外费用",
price_formula: "报价规则",
quantity_formula: "数量口径",
pricing_scope: "计费口径",
charge_policy: "费用性质",
evidence_level: "证据强度",
requires_manual_review: "需人工核对",
candidate_count: "候选数量",
base_price: "基础团费",
base_price_status: "报价状态",
base_price_min_adult: "成人结算汇总最低价(参考)",
base_price_max_adult: "成人结算汇总最高价(参考)",
base_price_child_min: "儿童结算汇总最低价(参考)",
base_price_child_max: "儿童结算汇总最高价(参考)",
single_room_diff_min: "单房差汇总最低价(参考)",
single_room_diff_max: "单房差汇总最高价(参考)",
tour_period_text: "团期",
date_range_text: "日期范围",
valid_start_date: "开始日期",
valid_end_date: "结束日期",
departure_date: "出发日期",
accommodation_standard_text: "住宿标准",
accommodation_tier: "住宿档位",
is_default: "默认档位",
adult_settlement_text: "成人结算价",
child_settlement_text: "儿童结算价(不占床,不含早)",
free_ticket_settlement_text: "免票结算价",
single_room_diff_text: "单房差",
ticket_discount_policy_text: "门票优惠政策",
self_pay_and_booking_items_text: "自费项目",
remark_text: "备注",
raw_row_text: "原表行内容",
source_row: "来源行号",
source_sheet: "来源工作表",
price_plan_id: "价格档位ID",
plan_name: "价格档位",
price_plan_name: "价格档位",
price_period_label: "价格日期段",
adult_price_min: "成人结算最低价",
adult_price_max: "成人结算最高价",
adult_price: "成人结算价",
child_price_min: "儿童结算最低价",
child_price_max: "儿童结算最高价",
child_price: "儿童结算价",
single_room_diff: "单房差",
adult_settlement_source: "成人结算原文",
child_settlement_source: "儿童结算原文",
single_room_diff_source: "单房差原文",
remaining_stock: "余位",
price_status: "价格状态",
requires_supplier_confirm: "需供应商确认",
currency: "币种",
source_text: "价格事件依据",
recorded_at: "记录时间",
base_price_text: "基础报价说明",
price_options: "报价选项",
price_option_count: "报价选项数",
mandatory_fee_text: "必付费用说明",
ticket_refund_policy: "证件退费政策",
quote_formula: "总价公式",
pricing_notes: "报价注意事项",
pricing_source_files: "报价来源文件",
price_match_confidence: "报价匹配置信度",
duration_days: "天数",
duration_nights: "晚数",
route_immutable: "路线固定",
route_fixed: "固定线路",
small_group_supported: "支持小包团",
group_size_min: "最少成团人数",
group_size_max: "最多服务人数",
included_summary: "住宿标准",
optional_summary: "自费项目",
mandatory_summary: "基础门票/必付说明",
policy_notes: "政策/风险提示",
parent_attraction_name: "所属主景区",
primary_image_url: "首图URL",
fee_name: "费用项目",
fee_type: "费用类型",
amount_text: "价格",
price_text: "价格说明",
seat_count: "座位数",
capacity: "容量",
safe_passenger_capacity: "建议游客容量",
business_passenger_capacity: "业务预占容量",
recommended_passenger_min: "建议最少人数",
recommended_passenger_max: "建议最多人数",
luggage_capacity: "行李容量说明",
vehicle_group: "车型分组",
vehicle_grade: "车型等级",
vehicle_count_formula: "车辆数量公式",
service_rights: "服务权益",
supplier_price_status: "供应商报价状态",
pricing_pending_reason: "待补报价原因",
child_price_status: "儿童价说明",
inclusion_status: "包含状态",
mandatory_level: "消费属性",
scenic_target_name: "所属景区/景点",
source_product_name: "来源产品",
route_role: "路线角色",
parent_scenic_area_name: "所属景区",
recommendation_note: "推荐说明",
drive_distance_km: "驾车距离km",
drive_duration_min: "驾车时间分钟",
route_line_name: "所属线路",
route_sequence_label: "站点序号",
route_display_name: "线路站点名",
route_stop_sequence: "全程站点顺序",
sub_attraction_sequence: "景区内游览点",
route_day_summary: "每日路线摘要",
route_display_text: "线路说明",
global_stop_order: "全程顺序",
day_stop_order: "当日顺序",
station_like_name: "站点显示名",
total_stop_count: "站点数量",
previous_stop_name: "上一站",
next_stop_name: "下一站",
day_index: "第几天",
route_path: "当日路线",
route_summary: "简易行程",
meal_text: "用餐安排",
meal_plan: "用餐安排",
accommodation_text: "住宿安排",
transport_summary: "交通安排",
created_at: "创建时间",
updated_at: "更新日期",
updatedAt: "更新日期",
reviewed_at: "审核时间",
published_at: "发布时间",
source_file: "来源文件",
last_updated_at: "更新日期",
last_poi_nearby_import_at: "最近资源导入时间",
admin_region_name: "所属行政区",
admin_region_level: "行政区层级",
route_region_path: "行政区路径",
city_or_area: "地点/区域",
};
const DETAIL_FIELD_ORDER = [
"event_id", "id", "event_date",
"natural_key", "product_id", "route_line_no", "route_line_id", "price_plan_id", "item_id", "binding_id", "scenic_area_id", "attraction_id", "sub_attraction_id",
"type", "subtype", "status", "binding_type", "default_text", "extra_expense", "price_formula", "quantity_formula", "pricing_scope", "charge_policy", "evidence_level", "requires_manual_review", "candidate_count",
"route_fixed", "small_group_supported", "group_size_min", "group_size_max", "included_summary", "optional_summary", "mandatory_summary",
"tour_period_text", "date_range_text", "valid_start_date", "valid_end_date", "departure_date",
"accommodation_standard_text", "accommodation_tier", "is_default", "plan_name", "price_plan_name", "price_period_label",
"adult_settlement_text", "child_settlement_text", "free_ticket_settlement_text", "single_room_diff_text",
"ticket_discount_policy_text", "self_pay_and_booking_items_text", "remark_text", "raw_row_text", "source_file", "source_sheet", "source_row",
"adult_price_min", "adult_price_max", "adult_price", "child_price_min", "child_price_max", "child_price", "single_room_diff", "adult_settlement_source", "child_settlement_source", "single_room_diff_source",
"remaining_stock", "price_status", "requires_supplier_confirm", "currency", "source_text", "recorded_at",
"base_price_status", "base_price", "base_price_min_adult", "base_price_max_adult", "base_price_child_min", "base_price_child_max", "single_room_diff_min", "single_room_diff_max",
"base_price_text", "price_option_count", "mandatory_fee_text", "ticket_refund_policy", "quote_formula", "pricing_notes", "pricing_source_files", "price_match_confidence", "price_options",
"duration_days", "duration_nights", "route_immutable",
"route_line_name", "route_sequence_label", "global_stop_order", "day_index", "day_stop_order",
"station_like_name", "route_display_name", "route_path", "route_summary", "route_stop_sequence", "sub_attraction_sequence", "route_day_summary",
"meal_text", "meal_plan", "accommodation_text", "transport_summary", "previous_stop_name", "next_stop_name",
"admin_region_name", "admin_region_level", "route_region_path", "city_or_area",
"parent_attraction_name", "description", "policy_notes", "evidence_quote", "confidence", "review_status",
"route_role", "parent_scenic_area_name", "recommendation_note",
"fee_name", "fee_type", "amount_text", "price_text", "adult_price", "child_price", "child_price_status",
"seat_count", "capacity", "safe_passenger_capacity", "business_passenger_capacity", "recommended_passenger_min", "recommended_passenger_max",
"luggage_capacity", "vehicle_group", "vehicle_grade", "vehicle_count_formula", "service_rights", "supplier_price_status", "pricing_pending_reason",
"inclusion_status", "mandatory_level", "scenic_target_name", "source_product_name",
"drive_distance_km", "drive_duration_min",
"address", "district", "tel", "scenic_level", "rating", "cost", "open_time", "lat", "lng",
"business_area", "summary", "history", "features",
"updated_at", "updatedAt", "last_updated_at", "last_poi_nearby_import_at", "reviewed_at", "published_at", "created_at",
];
const DETAIL_UPDATE_FIELD_KEYS = [
"updated_at",
"updatedAt",
"last_updated_at",
"last_poi_nearby_import_at",
"reviewed_at",
"published_at",
"created_at",
];
const HIDDEN_DETAIL_FIELDS = new Set([
"photo_urls", "image_url", "cover_url",
"name", "display_name", "label", "title",
"place_type", "category", "station_type",
"element_id", "place_id", "shadow_node",
"event_type", "event_time", "event_date_norm",
"time_text", "time_norm",
"source", "source_id", "source_name", "source_url",
"evidence_id", "evidence_url",
"schema_version", "extraction_schema",
"source_file", "source_sheet", "source_row",
"base_price_min_adult", "base_price_max_adult",
"base_price_child_min", "base_price_child_max",
"single_room_diff_min", "single_room_diff_max",
"free_ticket_settlement_text", "single_room_diff_text",
]);
const TOUR_PERIOD_DUPLICATE_FIELDS = new Set([
"date_range_text",
"valid_start_date",
"valid_end_date",
]);
function isUsefulValue(value: any): boolean {
const s = String(value ?? "").trim();
return !!s && !["None", "null", "undefined", "-"].includes(s);
}
function selectedDisplayName(node: any): string {
const props = node?.properties || {};
return props.name || props.line_name || props.display_name || node?.label || node?.name || "节点详情";
}
function isPriorityCanvasLabelNode(node: GNode): boolean {
return PRIORITY_LABEL_GROUPS.has(node.group);
}
function sourceLabel(value?: any): string {
const s = String(value ?? "").trim();
if (!s || s === "kg_schema_v1") return "";
if (s === "amap") return "高德地图";
if (s === "baidu_baike" || s === "baidu_baike_huaxi_park") return "百度百科";
return s;
}
function detailSourceName(props: Record<string, any> = {}): string {
return (
sourceLabel(props.source_name) ||
sourceLabel(props.source) ||
sourceLabel(props.evidence_id)
);
}
function detailSourceUrl(props: Record<string, any> = {}): string {
const url = String(props.source_url || props.evidence_url || "").trim();
return /^https?:\/\//i.test(url) ? url : "";
}
function detailUpdatedAt(props: Record<string, any> = {}): { key: string; value: any } | null {
for (const key of DETAIL_UPDATE_FIELD_KEYS) {
if (isUsefulValue(props[key])) return { key, value: props[key] };
}
return null;
}
function formatDetailDate(value: any): string {
const raw = String(value ?? "").trim();
if (!raw) return "";
if (/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}/.test(raw)) {
return raw.replace("T", " ").replace(/\.\d+/, "").slice(0, 19);
}
if (/^\d{4}\.\d{2}\.\d{2}/.test(raw)) return raw;
return raw;
}
function shouldSkipDetailField(key: string, props: Record<string, any>): boolean {
if (HIDDEN_DETAIL_FIELDS.has(key)) return true;
if (TOUR_PERIOD_DUPLICATE_FIELDS.has(key) && isUsefulValue(props.tour_period_text)) return true;
if (key === "optional_summary" && isUsefulValue(props.self_pay_and_booking_items_text)) return true;
return false;
}
function firstUsefulValue(props: Record<string, any>, keys: string[]): any {
for (const key of keys) {
if (isUsefulValue(props[key])) return props[key];
}
return "";
}
function booleanText(value: any): string {
if (!isUsefulValue(value)) return "";
if (typeof value === "boolean") return value ? "是" : "否";
const normalized = String(value).trim().toLowerCase();
if (["true", "1", "yes", "y"].includes(normalized)) return "是";
if (["false", "0", "no", "n"].includes(normalized)) return "否";
return String(value);
}
function formatRouteSummary(value: string): string {
return value
.replace(/\s*[;;]\s*/g, "\n")
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.join("\n");
}
function formatSelfPayItems(value: string): string[] {
const scenicBreakpoints = [
"黄果树",
"小七孔",
"西江",
"镇远",
"梵净山",
"青岩",
"天星桥",
"陡坡塘",
"荔波",
];
const scenicPattern = scenicBreakpoints.join("|");
return value
.replace(/\r/g, "")
.replace(/\s*[;;]\s*/g, "\n")
.replace(/([::])\s*/g, "$1\n")
.replace(new RegExp(`\\s+(?=(${scenicPattern}))`, "g"), "\n")
.replace(/\s+(?=[((][^))]*(?:电瓶车|索道|保险))/g, "\n")
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
}
function addTourProductDetailItem(
items: DetailItem[],
key: string,
label: string,
value: any,
renderAs?: DetailItem["renderAs"]
) {
items.push({ key, label, value: isUsefulValue(value) ? value : "-", renderAs });
}
function tourProductDetailItems(node: any): DetailItem[] {
const props = node?.properties || {};
const items: DetailItem[] = [];
let tourPeriod = firstUsefulValue(props, ["tour_period_text", "date_range_text"]);
if (!isUsefulValue(tourPeriod) && isUsefulValue(props.valid_start_date) && isUsefulValue(props.valid_end_date)) {
tourPeriod = `${props.valid_start_date}至${props.valid_end_date}`;
}
const accommodation = firstUsefulValue(props, ["accommodation_standard_text", "included_summary"]);
const selfPay = firstUsefulValue(props, ["self_pay_and_booking_items_text", "optional_summary"]);
const selfPayKey = isUsefulValue(props.self_pay_and_booking_items_text)
? "self_pay_and_booking_items_text"
: "optional_summary";
const policyNotes = firstUsefulValue(props, ["policy_notes", "remark_text"]);
const updated = detailUpdatedAt(props);
addTourProductDetailItem(items, "product_id", "产品ID", props.product_id);
addTourProductDetailItem(items, "name", "名称", selectedDisplayName(node));
addTourProductDetailItem(items, "node_group", "类型", node?.group || props.type, "tag");
addTourProductDetailItem(items, "tour_period_text", "团期", tourPeriod);
addTourProductDetailItem(items, "route_summary", "简易行程", props.route_summary);
addTourProductDetailItem(items, "duration_days", "天数", props.duration_days);
addTourProductDetailItem(items, "duration_nights", "晚数", props.duration_nights);
addTourProductDetailItem(items, "small_group_supported", "支持小包团", booleanText(props.small_group_supported));
addTourProductDetailItem(items, "group_size_max", "最多服务人数", props.group_size_max);
addTourProductDetailItem(items, "accommodation_standard_text", "住宿标准", accommodation);
addTourProductDetailItem(items, "adult_settlement_text", "成人结算价", props.adult_settlement_text);
addTourProductDetailItem(items, "child_settlement_text", "儿童结算价(不占床,不含早)", props.child_settlement_text);
addTourProductDetailItem(items, selfPayKey, "自费项目", selfPay);
addTourProductDetailItem(items, "policy_notes", "政策/风险提示", policyNotes);
addTourProductDetailItem(items, updated?.key || "updated_at", "更新日期", updated?.value);
return items;
}
function detailItems(props: Record<string, any> = {}): DetailItem[] {
const items: DetailItem[] = [];
DETAIL_FIELD_ORDER.forEach((key) => {
if (key === "id" && props.event_id && props.event_id === props.id) return;
if (key === "route_line_id" && props.product_id && String(props.route_line_id) === String(props.product_id)) return;
if (key === "price_plan_name" && props.plan_name && String(props.price_plan_name) === String(props.plan_name)) return;
if (key === "single_room_diff_min" && props.single_room_diff && String(props.single_room_diff_min) === String(props.single_room_diff)) return;
if (shouldSkipDetailField(key, props)) return;
if (isUsefulValue(props[key])) {
items.push({ key, label: DETAIL_FIELD_LABELS[key], value: String(props[key]) });
}
});
const readableSource = detailSourceName(props);
if (readableSource) {
items.push({ key: "source_display", label: "来源", value: readableSource });
}
const sourceUrl = detailSourceUrl(props);
if (sourceUrl) {
items.push({ key: "source_url_display", label: "来源链接", value: sourceUrl });
}
if (!items.some((item) => item.label === "更新日期")) {
const updated = detailUpdatedAt(props);
if (updated) {
items.push({ key: updated.key, label: "更新日期", value: updated.value });
}
}
Object.entries(props).forEach(([key, value]) => {
if (shouldSkipDetailField(key, props) || DETAIL_FIELD_LABELS[key] || !isUsefulValue(value)) return;
// Keep the drawer human-facing: unknown raw English/technical keys stay hidden
// until we intentionally map them to a Chinese label.
});
return items;
}
function renderDetailValue(item: DetailItem) {
if (item.renderAs === "tag") {
const value = String(item.value ?? "").trim();
if (!isUsefulValue(value)) return "-";
return (
<Tag style={{ background: colorOf(value), color: "#fff", borderColor: colorOf(value) }}>
{value}
</Tag>
);
}
if (item.key === "source_url_display") {
return (
<a href={String(item.value)} target="_blank" rel="noreferrer" style={{ wordBreak: "break-all" }}>
{item.value}
</a>
);
}
if (DETAIL_UPDATE_FIELD_KEYS.includes(item.key)) {
return formatDetailDate(item.value) || "-";
}
const value = String(item.value ?? "");
if (item.key === "self_pay_and_booking_items_text" || item.key === "optional_summary") {
const lines = formatSelfPayItems(value);
if (lines.length > 1) {
return (
<div style={{ display: "grid", gap: 6, lineHeight: 1.65 }}>
{lines.map((line, index) => {
const isHeading = index === 0 && /[::]$/.test(line);
return isHeading ? (
<div key={`${line}-${index}`} style={{ fontWeight: 600 }}>
{line}
</div>
) : (
<div
key={`${line}-${index}`}
style={{ display: "grid", gridTemplateColumns: "12px minmax(0, 1fr)", gap: 6 }}
>
<span style={{ color: "#8c8c8c" }}>•</span>
<span style={{ wordBreak: "break-word" }}>{line}</span>
</div>
);
})}
</div>
);
}
}
const formattedValue = item.key === "route_summary" ? formatRouteSummary(value) : value;
const maxLen = [
"route_stop_sequence", "route_day_summary", "route_display_text",
"route_path", "meal_text", "accommodation_text", "transport_summary",
"route_summary", "tour_period_text", "adult_settlement_text",
"child_settlement_text", "free_ticket_settlement_text", "single_room_diff_text",
"ticket_discount_policy_text", "self_pay_and_booking_items_text", "remark_text", "raw_row_text",
].includes(item.key) ? 520 : 120;
const text = formattedValue.length > maxLen ? `${formattedValue.slice(0, maxLen)}…` : formattedValue;
return <span style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>{text}</span>;
}
function cypherString(value: any): string {
return String(value).replace(/\\/g, "\\\\").replace(/'/g, "\\'");
}
function nodeLookupCypher(node: any): string | null {
const props = node?.properties || {};
const candidates = [
["natural_key", props.natural_key],
["element_id", props.element_id],
["place_id", props.place_id],
["line_name", props.line_name],
["id", props.id],
["name", props.name || node?.name],
].filter(([, value]) => value !== undefined && value !== null && String(value).trim());
if (!candidates.length) return null;
const [key, value] = candidates[0];
return `MATCH (n) WHERE n.${key} = '${cypherString(value)}' RETURN n LIMIT 1`;
}
function collectTrafficInfo(
node: any,
graph: { nodes: Map<string, GNode>; edges: Map<string, GEdge> }
): { stations: GNode[]; lines: GNode[] } | null {
if (!node || !["Place", "地点", "景点", "美食", "酒店", "商场"].includes(node.group)) return null;
const stations = new Map<string, GNode>();
const lines = new Map<string, GNode>();
graph.edges.forEach((edge) => {
if (edge.label !== "NEAR_TRANSIT") return;
const otherId = edge.from === node.id ? edge.to : edge.to === node.id ? edge.from : "";
if (!otherId) return;
const other = graph.nodes.get(otherId);
if (other && ["BusStop", "MetroStation", "TransitStop", "公交站", "地铁站", "交通站点"].includes(other.group)) {
stations.set(other.id, other);
}
});
graph.edges.forEach((edge) => {
if (edge.label !== "STOPS_AT") return;
const from = graph.nodes.get(edge.from);
const to = graph.nodes.get(edge.to);
if (from?.group === "BusLine" && to && stations.has(to.id)) lines.set(from.id, from);
if (to?.group === "BusLine" && from && stations.has(from.id)) lines.set(to.id, to);
});
if (!stations.size && !lines.size) return null;
return { stations: [...stations.values()], lines: [...lines.values()] };
}
function seededForcePosition(
node: GNode,
index: number,
groupIndex: number,
groupCount: number,
degree: number,
maxDegree: number
): { x: number; y: number } {
const goldenAngle = Math.PI * (3 - Math.sqrt(5));
const centrality = Math.sqrt(degree / Math.max(1, maxDegree));
const groupAngle = (Math.PI * 2 * groupIndex) / Math.max(1, groupCount);
const angle = groupAngle + index * goldenAngle * 0.18;
const radius = 120 + (1 - centrality) * 420 + Math.sqrt(index + 1) * 18;
const squash = 0.72;
const jitterSeed = String(node.id).split("").reduce((sum, ch) => sum + ch.charCodeAt(0), 0);
const jitter = ((jitterSeed % 37) - 18) * 1.8;
return {
x: Math.cos(angle) * radius + jitter,
y: Math.sin(angle) * radius * squash - jitter,
};
}
export default function GraphCanvas() {
const chartRef = useRef<HTMLDivElement>(null);
const wrapRef = useRef<HTMLDivElement>(null);
const chartInstance = useRef<echarts.ECharts | null>(null);
// Accumulated graph data (Maps for dedupe + incremental expand)
const dataRef = useRef<{ nodes: Map<string, GNode>; edges: Map<string, GEdge> }>({
nodes: new Map(), edges: new Map(),
});
const clickTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [loading, setLoading] = useState(false);
const [cypher, setCypher] = useState(defaultCypherForGraph(getProjectContext().graphName));
const [overview, setOverview] = useState<Record<string, any>>({});
const [selectedNode, setSelectedNode] = useState<any>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [photoIndex, setPhotoIndex] = useState(0);
const [photoHover, setPhotoHover] = useState(false);
const [resultInfo, setResultInfo] = useState("");
const [queryMeta, setQueryMeta] = useState<Record<string, any> | null>(null);
const [fullscreen, setFullscreen] = useState(false);
// ── Render accumulated data into the chart ──
const render = useCallback(() => {
const chart = chartInstance.current;
if (!chart) return;
const nodes = [...dataRef.current.nodes.values()];
const edges = [...dataRef.current.edges.values()];
chart.clear();
chart.resize();
if (!nodes.length) return;
// Degree → node importance
const deg: Record<string, number> = {};
edges.forEach((e) => {
deg[e.from] = (deg[e.from] || 0) + 1;
deg[e.to] = (deg[e.to] || 0) + 1;
});
const maxDeg = Math.max(1, ...Object.values(deg));
// Only "hub" nodes get a permanent label — rest reveal on hover.
// This is the single biggest readability win on dense graphs.
const labelThreshold = Math.max(3, maxDeg * 0.45);
const groups = [...new Set(nodes.map((n) => n.group))] as string[];
const groupOffsets = new Map<string, number>();
const nodeCount = Math.max(1, nodes.length);
const chartNodes = nodes.map((n) => {
const d = deg[n.id] || 0;
const sizeBase = nodeCount > 220 ? 10 : 14;
const sizeRange = nodeCount > 220 ? 26 : 32;
const size = sizeBase + Math.sqrt(d / maxDeg) * sizeRange;
const nodeName = selectedDisplayName(n);
const isPriorityLabel = isPriorityCanvasLabelNode(n);
const groupIndex = Math.max(0, groups.indexOf(n.group));
const offset = groupOffsets.get(n.group) || 0;
groupOffsets.set(n.group, offset + 1);
const pos = seededForcePosition(n, offset, groupIndex, groups.length, d, maxDeg);
return {
id: n.id,
name: nodeName,
symbolSize: size,
category: groups.indexOf(n.group),
value: d,
x: pos.x,
y: pos.y,
itemStyle: { color: colorOf(n.group), borderColor: "#fff", borderWidth: 1.5 },
label: {
show: isPriorityLabel || d >= labelThreshold,
position: "right",
distance: isPriorityLabel ? 7 : 5,
color: "#333",
fontSize: isPriorityLabel ? 12 : 11,
fontWeight: isPriorityLabel ? 600 : 400,
backgroundColor: isPriorityLabel ? "rgba(255,255,255,0.86)" : "transparent",
borderRadius: isPriorityLabel ? 3 : 0,
padding: isPriorityLabel ? [2, 5] : 0,
formatter: "{b}",
},
group: n.group,
properties: n.properties,
};
});
const chartEdges = edges.map((e) => ({
id: e.id,
source: e.from,
target: e.to,
value: e.label,
}));
const edgeDensity = chartEdges.length / nodeCount;
const repulsion = Math.min(1400, Math.max(320, 1300 - Math.sqrt(nodeCount) * 34 + edgeDensity * 70));
const edgeLength: [number, number] = nodeCount > 700
? [48, 130]
: nodeCount > 260
? [70, 185]
: [90, 240];
const initialZoom = nodeCount > 700 ? 0.52 : nodeCount > 220 ? 0.66 : 0.82;
const edgeOpacity = nodeCount > 260 ? 0.24 : nodeCount > 120 ? 0.32 : 0.5;
chart.setOption(
{
backgroundColor: "#fafbfc",
tooltip: {
confine: true,
formatter: (params: any) => {
if (params.dataType === "node") {
const d = params.data;
const props = d.properties
? Object.entries(d.properties)
.filter(([k, v]) => DETAIL_FIELD_LABELS[k] && !HIDDEN_DETAIL_FIELDS.has(k) && isUsefulValue(v))
.slice(0, 5)
.map(([k, v]) => `${DETAIL_FIELD_LABELS[k]}: ${v}`)
.join("<br/>")
: "";
return `<b>${d.name}</b><br/>类型: ${d.group} 度: ${d.value}<br/>${props}<br/><i style="color:#999">双击展开关联</i>`;
}
if (params.dataType === "edge") return params.data.value || "";
return params.name;
},
},
legend: {
data: groups.map((g) => ({ name: g })),
bottom: 6,
type: "scroll",
textStyle: { fontSize: 11 },
},
animationDuration: 600,
animationEasingUpdate: "quinticInOut",
series: [
{
type: "graph",
layout: "force",
data: chartNodes,
links: chartEdges,
categories: groups.map((g) => ({ name: g, itemStyle: { color: colorOf(g) } })),
roam: true,
draggable: true,
center: ["50%", "48%"],
zoom: initialZoom,
scaleLimit: { min: 0.2, max: 4 },
force: {
repulsion,
// higher gravity keeps disconnected components from flying to corners
gravity: nodeCount > 700 ? 0.05 : 0.08,
edgeLength,
friction: 0.52,
layoutAnimation: chartNodes.length < 700,
},
labelLayout: { hideOverlap: false, moveOverlap: "shiftY" },
label: {
show: false,
position: "right",
fontSize: 11,
color: "#333",
formatter: "{b}",
},
emphasis: {
focus: "adjacency",
scale: true,
label: { show: true, fontWeight: "bold", fontSize: 12 },
itemStyle: { borderColor: "#fff", borderWidth: 2, shadowBlur: 18, shadowColor: "rgba(0,0,0,0.25)" },
// hovered edges turn accent-blue so they pop against the neutral grey base
lineStyle: { color: "#1677ff", width: 2.4, opacity: 1, curveness: 0.1 },
},
blur: {
itemStyle: { opacity: 0.1 },
lineStyle: { opacity: 0.035 },
label: { opacity: 0.08 },
},
// neutral grey edges — kills the rainbow-spaghetti look; colour
// returns only on hover (emphasis above)
lineStyle: { color: "#c5cad3", curveness: 0.06, opacity: edgeOpacity, width: 0.9 },
edgeSymbol: ["none", "arrow"],
edgeSymbolSize: 4,
edgeLabel: {
show: false,
fontSize: 9,
color: "#999",
formatter: (p: any) => p.data.value,
},
},
],
} as echarts.EChartsOption,
{ notMerge: true }
);
window.setTimeout(() => chart.resize(), 0);
}, []);
const ingestGraphResult = useCallback(
(data: any, mode: "replace" | "merge") => {
if (!data.nodes || data.nodes.length === 0) {
message.info(mode === "merge" ? "该节点没有更多关联" : "查询无结果");
return;
}
if (mode === "replace") {
dataRef.current.nodes.clear();
dataRef.current.edges.clear();
}
const before = dataRef.current.nodes.size;
data.nodes.forEach((n: GNode) => dataRef.current.nodes.set(n.id, n));
(data.edges || []).forEach((e: GEdge) => dataRef.current.edges.set(e.id, e));
render();
const total = `${dataRef.current.nodes.size} 节点, ${dataRef.current.edges.size} 边`;
setResultInfo(`${total} · ${modeLabel(data.mode)}`);
setQueryMeta({
mode: data.mode,
cypher: data.cypher,
generated_cypher: data.generated_cypher,
fallback_reason: data.fallback_reason,
reason: data.reason,
});
if (mode === "merge") {
const added = dataRef.current.nodes.size - before;
message.success(added > 0 ? `展开 +${added} 个关联节点(共 ${total})` : "无新增关联");
} else {
message.success(`${total} · ${modeLabel(data.mode)}`);
}
},
[render]
);
// ── Fetch a Cypher query; replace = fresh query, merge = expand ──
const fetchCypher = useCallback(
async (q: string, mode: "replace" | "merge") => {
if (!q.trim() || !chartInstance.current) return;
setLoading(true);
try {
if (mode === "replace" && isFullGraphQuery(q)) {
dataRef.current.nodes.clear();
dataRef.current.edges.clear();
let totalRows = 0;
let batches = 0;
const maxRows = fullGraphQueryLimit(q);
const graphNameForQuery = overview.graph_name || getProjectContext().graphName;
const travelItemFullGraph = isTravelItemGraphName(graphNameForQuery);
for (let offset = 0; offset < maxRows; offset += FULL_GRAPH_BATCH_SIZE) {
const batchSize = Math.min(FULL_GRAPH_BATCH_SIZE, maxRows - offset);
const batchCypher = travelItemFullGraph
? [
"MATCH (n)-[r]->(m)",
"WHERE NOT n:TourPriceEvent AND NOT m:TourPriceEvent",
"AND NOT n:RoomPriceEvent AND NOT m:RoomPriceEvent",
"AND NOT n:OntologySchema AND NOT m:OntologySchema",
"AND NOT n:OntologyEntityType AND NOT m:OntologyEntityType",
"AND NOT n:OntologyRelationType AND NOT m:OntologyRelationType",
"AND NOT n:OntologyField AND NOT m:OntologyField",
`RETURN n,r,m SKIP ${offset} LIMIT ${batchSize}`,
].join(" ")
: `${FULL_GRAPH_BASE_CYPHER} SKIP ${offset} LIMIT ${batchSize}`;
const { data } = await api.post("/graph/query", { cypher: batchCypher, limit: batchSize });
(data.nodes || []).forEach((n: GNode) => dataRef.current.nodes.set(n.id, n));
(data.edges || []).forEach((e: GEdge) => dataRef.current.edges.set(e.id, e));
totalRows += Number(data.row_count || 0);
batches += 1;
if (Number(data.row_count || 0) < batchSize) break;
}
render();
const total = `${dataRef.current.nodes.size} 节点, ${dataRef.current.edges.size} 边`;
setResultInfo(`${total} · 全图抽样加载 ${batches} 批`);
setQueryMeta({
mode: "cypher",
cypher: q,
reason: travelItemFullGraph
? `旅行社项目全图抽样默认过滤每日价格/房价事件和本体说明节点,避免事件快照造成重复观感;按 ${FULL_GRAPH_BATCH_SIZE} 行分批加载,最多 ${maxRows} 条关系,已读取 ${totalRows} 条。`
: `全图抽样按 ${FULL_GRAPH_BATCH_SIZE} 行分批加载,最多 ${maxRows} 条关系,已读取 ${totalRows} 条。`,
});
message.success(`${total} · 全图抽样加载完成`);
return;
}
const { data } = await api.post("/graph/query", { cypher: q, limit: FULL_GRAPH_LIMIT });
ingestGraphResult(data, mode);
} catch (err: any) {
message.error(err?.response?.data?.detail || "查询失败");
} finally {
setLoading(false);
}
},
[ingestGraphResult, overview.graph_name]
);
const fetchSmartQuery = useCallback(
async (q: string, mode: "replace" | "merge") => {
const text = q.trim();
if (!text || !chartInstance.current) return;
if (isCypherQuery(text)) {
await fetchCypher(text, mode);
return;
}
setLoading(true);
try {
const { data } = await api.post("/graph/nl-query", { query: text, limit: FULL_GRAPH_LIMIT });
ingestGraphResult(data, mode);
} catch (err: any) {
message.error(err?.response?.data?.detail || "自然语言查询失败");
} finally {
setLoading(false);
}
},
[fetchCypher, ingestGraphResult]
);
const runQuery = useCallback(
(query?: string) => fetchSmartQuery(query || cypher, "replace"),
[cypher, fetchSmartQuery]
);
const openNodeDrawer = useCallback(async (node: any) => {
setSelectedNode(node);
setDrawerOpen(true);
const lookup = nodeLookupCypher(node);
if (!lookup) return;
try {
const { data } = await api.post("/graph/query", { cypher: lookup, limit: 1 });
const freshNode = data?.nodes?.[0];
if (freshNode) {
setSelectedNode({ ...freshNode, value: node.value ?? freshNode.value });
}
} catch {
// Keep the original graph node visible even if the detail refresh fails.
}
}, []);
// ── Init chart + interactions ──
useEffect(() => {
if (!chartRef.current || chartInstance.current) return;
const chart = echarts.init(chartRef.current, undefined, { renderer: "canvas" });
chartInstance.current = chart;
// Single click → detail drawer (debounced so dblclick doesn't also open it)
chart.on("click", (params: any) => {
if (params.dataType !== "node") return;
if (clickTimer.current) clearTimeout(clickTimer.current);
clickTimer.current = setTimeout(() => {
openNodeDrawer(params.data);
}, 240);
});
// Double click → expand neighbors (capped at 30)
chart.on("dblclick", (params: any) => {
if (params.dataType !== "node") return;
if (clickTimer.current) clearTimeout(clickTimer.current);
const props = params.data.properties || {};
const candidates = [
["natural_key", props.natural_key],
["element_id", props.element_id],
["place_id", props.place_id],
["line_name", props.line_name],
["id", props.id],
["name", props.name || params.data.name],
].filter(([, value]) => value !== undefined && value !== null && String(value).trim());
if (!candidates.length) return;
const [key, value] = candidates[0];
const safe = String(value).replace(/\\/g, "\\\\").replace(/'/g, "\\'");
fetchCypher(
`MATCH (n)-[r]-(m) WHERE n.${key} = '${safe}' RETURN n,r,m LIMIT 60`,
"merge"
);
});
const ro = new ResizeObserver(() => chart.resize());
if (wrapRef.current) ro.observe(wrapRef.current);
const handleResize = () => chart.resize();
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
ro.disconnect();
chart.dispose();
chartInstance.current = null;
if (clickTimer.current) {
clearTimeout(clickTimer.current);
clickTimer.current = null;
}
};
}, [fetchCypher, openNodeDrawer]);
// ── Fullscreen wiring ──
useEffect(() => {
const onFs = () => {
const isFs = !!document.fullscreenElement;
setFullscreen(isFs);
// wait for layout to settle, then refit
setTimeout(() => chartInstance.current?.resize(), 120);
};
document.addEventListener("fullscreenchange", onFs);
return () => document.removeEventListener("fullscreenchange", onFs);
}, []);
const toggleFullscreen = useCallback(() => {
const el = wrapRef.current;
if (!el) return;
if (!document.fullscreenElement) {
el.requestFullscreen?.().catch(() => message.warning("当前浏览器不支持全屏"));
} else {
document.exitFullscreen?.();
}
}, []);
const resetView = useCallback(() => {
render(); // notMerge re-layout resets zoom/pan
message.info("视图已重置");
}, [render]);
const exportPng = useCallback(() => {
const chart = chartInstance.current;
if (!chart) return;
const url = chart.getDataURL({ type: "png", pixelRatio: 2, backgroundColor: "#fafbfc" });
const a = document.createElement("a");
a.href = url;
a.download = `graph-${Date.now()}.png`;
a.click();
}, []);
// Load overview
useEffect(() => {
api.get("/graph/overview").then(({ data }) => setOverview(data)).catch(() => {});
}, []);
// Auto-run once connected
useEffect(() => {
if (chartInstance.current && overview.graph_name) {
const defaultQuery = defaultCypherForGraph(overview.graph_name || getProjectContext().graphName);
if (defaultQuery !== cypher) setCypher(defaultQuery);
const t = setTimeout(() => runQuery(defaultQuery), 400);
return () => clearTimeout(t);
}
}, [overview.graph_name]); // eslint-disable-line react-hooks/exhaustive-deps
const currentGraphName = overview.graph_name || getProjectContext().graphName;
const isTravelGraph = isAnyTravelGraphName(currentGraphName);
const isTravelItemGraph = isTravelItemGraphName(currentGraphName);
const isYunyouLiboGraph = isYunyouLiboGraphName(currentGraphName);
const queryTemplates = isYunyouLiboGraph ? [
{ label: "四类业务全景", cypher: LIBO_DEFAULT_CYPHER },
{ label: "全部关系抽样", cypher: GENERIC_DEFAULT_CYPHER },
{ label: "酒店", cypher: "MATCH (n:Hotel) RETURN n LIMIT 150" },
{ label: "美食", cypher: "MATCH (n:FoodPlace) RETURN n LIMIT 150" },
{ label: "交通设施", cypher: "MATCH (n:TransitFacility) RETURN n LIMIT 150" },
{ label: "景点", cypher: "MATCH (n:ScenicSpot) RETURN n LIMIT 120" },
{ label: "景区", cypher: "MATCH (n:ScenicArea) RETURN n LIMIT 20" },
{ label: "公交线路停靠", cypher: "MATCH (line:BusRoute)-[r:STOPS_AT]->(stop:BusStop) RETURN line,r,stop LIMIT 350" },
{ label: "公交相邻站", cypher: "MATCH (a:BusStop)-[r:NEXT_STOP]->(b:BusStop) RETURN a,r,b LIMIT 320" },
{ label: "景点归属", cypher: "MATCH (spot:ScenicSpot)-[r:PART_OF_SCENIC_AREA]->(parent) RETURN spot,r,parent LIMIT 120" },
{ label: "POI 所在荔波县", cypher: "MATCH (poi:Place)-[r:LOCATED_IN]->(area:Area) WHERE area.adcode = '522722' RETURN poi,r,area LIMIT 300" },
{
label: "POI 所属片区",
cypher: "MATCH (poi:Place)-[r:IN_H3_R9]->(cell:GeoCell)-[areaRel:LOCATED_IN]->(area:Area) RETURN poi,r,cell,areaRel,area LIMIT 50",
},
{ label: "行政区层级", cypher: "MATCH (child:Area)-[r:PART_OF]->(parent:Area) RETURN child,r,parent LIMIT 20" },
] : isTravelItemGraph ? [
{ label: "核心业务图", cypher: TRAVEL_ITEM_CORE_CYPHER },
{ label: "固定线路骨架", cypher: TRAVEL_ITEM_ROUTE_SKELETON_CYPHER },
{ label: "景区附近酒店餐饮", cypher: TRAVEL_ITEM_NEARBY_RESOURCE_CYPHER },
{ label: "路线-景区-nearby资源", cypher: TRAVEL_ITEM_ROUTE_NEARBY_RESOURCE_CYPHER },
{ label: "线路-默认资源", cypher: TRAVEL_ITEM_BINDING_CYPHER },
{ label: "产品报价依据", cypher: TRAVEL_ITEM_PRICE_CYPHER },
{ label: "产品价格档位", cypher: TRAVEL_ITEM_PRICE_PLAN_CYPHER },
{ label: "景区费用资源", cypher: TRAVEL_ITEM_REGION_RESOURCE_CYPHER },
{ label: "门票/二消/必付", cypher: TRAVEL_ITEM_FEE_CYPHER },
{ label: "产品-景区-资源", cypher: "MATCH (p:TourProduct)-[:PRODUCT_HAS_DAY]->(d:ProductDay)-[:DAY_HAS_STOP]->(s:RouteStop)-[visit:STOP_VISITS_ATTRACTION]->(a:ScenicAttraction) OPTIONAL MATCH (a)-[has:ATTRACTION_HAS_ITEM]->(i:TravelItem) RETURN p,d,s,visit,a,has,i LIMIT 320" },
{ label: "车辆服务资源", cypher: "MATCH (p:TourProduct)-[:PRODUCT_HAS_DAY]->(d:ProductDay)-[:DAY_HAS_STOP]->(s:RouteStop)-[use:STOP_USES_DEFAULT_RESOURCE]->(i:TravelItem) WHERE i.type = 'Vehicle' RETURN p,d,s,use,i LIMIT 180" },
{ label: "天星桥费用", cypher: "MATCH (s:RouteStop)-[ss:STOP_VISITS_SUB_ATTRACTION]->(sub:SubAttraction) WHERE sub.name CONTAINS '天星桥' OPTIONAL MATCH (s)-[use:STOP_USES_DEFAULT_RESOURCE]->(i:TravelItem) RETURN s,ss,sub,use,i LIMIT 160" },
{ label: "业务全量抽样", cypher: TRAVEL_ITEM_BUSINESS_SAMPLE_CYPHER },
{ label: "每日价格事件", cypher: TRAVEL_ITEM_PRICE_EVENT_CYPHER },
] : isTravelGraph ? [
{ label: "核心业务图", cypher: TRAVEL_CORE_CYPHER },
{ label: "全量抽样", cypher: `${FULL_GRAPH_BASE_CYPHER} LIMIT 1200` },
{ label: "业务总览", cypher: TRAVEL_OVERVIEW_CYPHER },
{ label: "行政区资源池", cypher: TRAVEL_REGION_RESOURCE_CYPHER },
{ label: "线路站点序列", cypher: TRAVEL_ROUTE_CYPHER },
{ label: "路线-景区-资源", cypher: TRAVEL_ROUTE_RESOURCE_CYPHER },
{ label: "行政区-路线", cypher: "MATCH (p:TourProduct)-[lineStop:PRODUCT_HAS_ORDERED_STOP]->(s:RouteStop)-[stopRegion:STOP_LOCATED_IN_REGION]->(region:AdministrativeRegion) OPTIONAL MATCH (s)-[visitArea:STOP_VISITS_SCENIC_AREA]->(sa:ScenicArea) RETURN p,lineStop,s,stopRegion,region,visitArea,sa LIMIT 360" },
{ label: "产品覆盖景区", cypher: "MATCH (p:TourProduct)-[r:PRODUCT_COVERS_SCENIC_AREA]->(sa:ScenicArea) RETURN p,r,sa LIMIT 160" },
{ label: "每日停靠到景区", cypher: "MATCH (p:TourProduct)-[r1:HAS_DAY]->(d:ProductDay)-[r2:DAY_HAS_STOP]->(s:RouteStop)-[r3:STOP_VISITS_SCENIC_AREA]->(sa:ScenicArea) OPTIONAL MATCH (s)-[sr:STOP_LOCATED_IN_REGION]->(region:AdministrativeRegion) RETURN p,r1,d,r2,s,r3,sa,sr,region LIMIT 260" },
{ label: "景区子景点", cypher: "MATCH (region:AdministrativeRegion)<-[loc:LOCATED_IN_REGION]-(sa:ScenicArea)-[r:SCENIC_AREA_HAS_ATTRACTION]->(a:ScenicAttraction) RETURN region,loc,sa,r,a LIMIT 120" },
{ label: "二消费用", cypher: "MATCH (x)-[r]->(f:TicketFee) WHERE type(r) = 'SCENIC_AREA_HAS_FEE' OR type(r) = 'ATTRACTION_HAS_FEE' RETURN x,r,f LIMIT 120" },
{ label: "黄果树费用", cypher: "MATCH (sa:ScenicArea)-[r:SCENIC_AREA_HAS_FEE]->(f:TicketFee) WHERE sa.name CONTAINS '黄果树' RETURN sa,r,f LIMIT 80" },
{ label: "景区间车程", cypher: "MATCH (a)-[r:DRIVING_ROUTE_METRIC]->(b) WHERE r.metric_scope = 'route_anchor_to_route_anchor' RETURN a,r,b ORDER BY r.drive_duration_min LIMIT 120" },
{ label: "景区附近酒店餐饮", cypher: "MATCH (region:AdministrativeRegion)<-[loc:LOCATED_IN_REGION]-(sa:ScenicArea)-[r:NEARBY_LOCATION_RESOURCE]->(x) OPTIONAL MATCH (x)-[xloc:LOCATED_IN_REGION]->(xRegion:AdministrativeRegion) RETURN region,loc,sa,r,x,xloc,xRegion ORDER BY r.drive_duration_min LIMIT 160" },
{ label: "产品-景区-资源", cypher: "MATCH (p:TourProduct)-[cover:PRODUCT_COVERS_SCENIC_AREA]->(sa:ScenicArea)-[near:NEARBY_LOCATION_RESOURCE]->(x) RETURN p,cover,sa,near,x ORDER BY near.drive_duration_min LIMIT 160" },
{ label: "槽位可选资源", cypher: "MATCH (s:ResourceSlot)-[r:SLOT_CAN_USE_LOCATION_RESOURCE]->(x) RETURN s,r,x LIMIT 120" },
{ label: "车辆服务资源", cypher: "MATCH (s:ResourceSlot)-[r:SLOT_CAN_USE_SERVICE_RESOURCE]->(v:VehicleResource) RETURN s,r,v LIMIT 120" },
] : [
{ label: "全部节点关系", cypher: GENERIC_DEFAULT_CYPHER },
{ label: "花溪公园知识", cypher: "查看花溪公园的事件、概念和组成景点" },
{ label: "交通可达", cypher: "花溪公园附近有哪些公交地铁可以到?" },
{ label: "花溪公园事件", cypher: "花溪公园有哪些历史事件?" },
{ label: "花溪公园概念", cypher: "花溪公园有哪些历史文化和游玩概念?" },
{ label: "景点 POI", cypher: "MATCH (n:Place) WHERE n.place_type = 'sight' RETURN n LIMIT 100" },
{ label: "美食 POI", cypher: "MATCH (n:Place) WHERE n.place_type = 'eat' RETURN n LIMIT 100" },
{ label: "酒店 POI", cypher: "MATCH (n:Place) WHERE n.place_type = 'hotel' RETURN n LIMIT 100" },
{ label: "商场 POI", cypher: "MATCH (n:Place) WHERE n.place_type = 'mall' RETURN n LIMIT 100" },
{ label: "Place 节点", cypher: "MATCH (n:Place) RETURN n LIMIT 80" },
{ label: "Area 节点", cypher: "MATCH (n:Area) RETURN n LIMIT 80" },
{ label: "路径 1~3跳", cypher: "MATCH p=(a:Place)-[*1..3]-(b:Area) RETURN p LIMIT 40" },
{ label: "HAS_TAG 关系", cypher: "MATCH (n)-[r:HAS_TAG]->(m) RETURN n,r,m LIMIT 100" },
{ label: "LOCATED_IN", cypher: "MATCH (a)-[r:LOCATED_IN]->(b) RETURN a,r,b LIMIT 100" },
];
const queryPlaceholder = isTravelItemGraph
? "输入自然语言或 Cypher,例如:黄果树天星桥有哪些必付小交通和可选二消?"
: isTravelGraph
? "输入自然语言或 Cypher,例如:一家5人6天想去黄果树小七孔西江梵净山,推荐什么线路?"
: isYunyouLiboGraph
? "输入自然语言或 Cypher,例如:查询荔波县的酒店、美食、交通设施或景点"
: "输入自然语言或 Cypher,例如:花溪公园有哪些历史事件?";
const selectedTrafficInfo = selectedNode ? collectTrafficInfo(selectedNode, dataRef.current) : null;
const selectedBusLines = splitBusLines(selectedNode?.properties?.bus_lines);
const displayLabelCounts = overview.visual_label_counts || overview.label_counts || [];
const drawerTitle = selectedDisplayName(selectedNode);
const isTourProductDetail = selectedNode?.group === "TourProduct";
const selectedPhotos = isTourProductDetail
? []
: splitPhotoUrls(selectedNode?.properties?.photo_urls || selectedNode?.properties?.image_url || selectedNode?.properties?.image_urls || selectedNode?.properties?.primary_image_url);
const activePhoto = selectedPhotos.length ? selectedPhotos[Math.min(photoIndex, selectedPhotos.length - 1)] : "";
const selectedDetailItems = isTourProductDetail
? tourProductDetailItems(selectedNode)
: detailItems(selectedNode?.properties || {});
useEffect(() => {
setPhotoIndex(0);
setPhotoHover(false);
}, [selectedNode?.id]);
return (
<div>
<h2>知识图谱浏览器</h2>
{/* Stats bar */}
<Row gutter={12} style={{ marginBottom: 16 }}>
<Col span={4}><Card size="small"><Statistic title="节点" value={overview.node_count ?? "-"} prefix={<NodeIndexOutlined />} valueStyle={{ fontSize: 20 }} /></Card></Col>
<Col span={4}><Card size="small"><Statistic title="关系" value={overview.relationship_count ?? "-"} prefix={<ApartmentOutlined />} valueStyle={{ fontSize: 20 }} /></Card></Col>
<Col span={4}><Card size="small"><Statistic title="分类" value={displayLabelCounts.length || "-"} valueStyle={{ fontSize: 20 }} /></Card></Col>
<Col span={4}>
<Card size="small">
<Statistic title="图谱" value={displayGraphName(overview.graph_name || getProjectContext().graphName)} valueStyle={{ fontSize: 16 }} />
</Card>
</Col>
<Col span={8}>
<Card size="small" bodyStyle={{ padding: "12px" }}>
<Space wrap>
{displayLabelCounts.map((l: any) => (
<Tag key={l.label} style={{ background: colorOf(l.label), color: "#fff", borderColor: colorOf(l.label) }}>
{l.label}: {l.count}
</Tag>
))}
</Space>
</Card>
</Col>
</Row>
{/* Query bar */}
<Card size="small" style={{ marginBottom: 12 }}>
<Space direction="vertical" style={{ width: "100%" }}>
<Space wrap style={{ width: "100%" }}>
<Input.Search
value={cypher}
onChange={(e) => setCypher(e.target.value)}
onSearch={(v) => runQuery(v)}
placeholder={queryPlaceholder}
style={{ minWidth: 480, flex: 1 }}
loading={loading}
enterButton={<><ThunderboltOutlined /> 智能查询</>}
allowClear
size="middle"
/>
<Button icon={<ReloadOutlined />} onClick={() => runQuery()} size="middle">刷新</Button>
</Space>
<Space wrap size={[0, 4]}>
<span style={{ color: "#999", fontSize: 12, whiteSpace: "nowrap" }}>快速查询:</span>
{queryTemplates.map((t) => (
<Tag
key={t.label}
color="processing"
style={{ cursor: "pointer", margin: 0 }}
onClick={() => { setCypher(t.cypher); setTimeout(() => runQuery(t.cypher), 100); }}
>
{t.label}
</Tag>
))}
</Space>
<Space split="·" wrap>
{resultInfo && <span style={{ color: "#52c41a", fontSize: 12 }}>{resultInfo}</span>}
<span style={{ color: "#999", fontSize: 12 }}>
{isTravelGraph
? "默认展示固定线路主干;费用/规则/审计关系按需查询"
: isYunyouLiboGraph
? "快速查询已按荔波县酒店、美食、交通、景点、公交和空间关系配置"
: "支持自然语言和 Cypher"} · 单击看详情 · 双击展开关联 · 滚轮缩放 · 拖拽平移
</span>
</Space>
{queryMeta?.cypher && (
<div style={{ fontSize: 12, color: "#666", lineHeight: 1.6 }}>
<Tag color={queryMeta.mode === "text_recall" ? "orange" : "blue"} style={{ marginInlineEnd: 6 }}>
{modeLabel(queryMeta.mode)}
</Tag>
<code style={{ whiteSpace: "normal", wordBreak: "break-word" }}>{queryMeta.cypher}</code>
{queryMeta.fallback_reason && (
<span style={{ marginLeft: 8, color: "#fa8c16" }}>
兜底原因:{queryMeta.fallback_reason}
</span>
)}
</div>
)}
</Space>
</Card>
{/* Graph canvas — wrapRef is the fullscreen target */}
<div
ref={wrapRef}
style={{
position: "relative",
height: fullscreen ? "100vh" : NORMAL_HEIGHT,
background: "#fafbfc",
border: fullscreen ? "none" : "1px solid #e8e8e8",
borderRadius: fullscreen ? 0 : 8,
overflow: "hidden",
}}
>
{/* Top-right toolbar */}
<div style={{ position: "absolute", top: 12, right: 12, zIndex: 20 }}>
<Space>
<Tooltip title="重置视图"><Button size="small" icon={<AimOutlined />} onClick={resetView} /></Tooltip>
<Tooltip title="导出 PNG"><Button size="small" icon={<DownloadOutlined />} onClick={exportPng} /></Tooltip>
<Tooltip title={fullscreen ? "退出全屏 (ESC)" : "全屏"}>
<Button
size="small"
type="primary"
icon={fullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
onClick={toggleFullscreen}
/>
</Tooltip>
</Space>
</div>
{loading && (
<div style={{ position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center", zIndex: 10, background: "rgba(255,255,255,0.6)", backdropFilter: "blur(2px)" }}>
<Spin tip="查询中..." />
</div>
)}
<div ref={chartRef} style={{ width: "100%", height: "100%" }} />
</div>
{/* Relationship counts */}
{overview.relationship_type_counts && (
<Card size="small" title={isTravelGraph ? "全库关系类型统计(默认图不全部展示)" : "关系类型统计"} style={{ marginTop: 12 }}>
<Space wrap>
{overview.relationship_type_counts.map((r: any) => (
<Tag key={r.type} color="purple">{r.type}: {r.count}</Tag>
))}
</Space>
</Card>
)}
{/* Node detail drawer */}
<Drawer
title={<><NodeIndexOutlined /> {drawerTitle}</>}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
width={520}
>
{selectedNode && (
<Space direction="vertical" style={{ width: "100%" }} size={12}>
<Descriptions column={1} size="small" bordered labelStyle={{ fontWeight: 600, width: isTourProductDetail ? 132 : 92 }}>
{isTourProductDetail ? (
selectedDetailItems.map((item) => (
<Descriptions.Item key={item.key} label={item.label}>
{renderDetailValue(item)}
</Descriptions.Item>
))
) : (
<>
<Descriptions.Item label="图谱ID">{selectedNode.id}</Descriptions.Item>
<Descriptions.Item label="名称">{drawerTitle}</Descriptions.Item>
<Descriptions.Item label="类型">
<Tag style={{ background: colorOf(selectedNode.group), color: "#fff", borderColor: colorOf(selectedNode.group) }}>
{selectedNode.group}
</Tag>
</Descriptions.Item>
{activePhoto && (
<Descriptions.Item label="实景图">
<div
onMouseEnter={() => setPhotoHover(true)}
onMouseLeave={() => setPhotoHover(false)}
style={{
position: "relative",
height: 220,
overflow: "hidden",
borderRadius: 6,
background: "#f3f5f8",
}}
>
<img
src={activePhoto}
alt={drawerTitle}
style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }}
/>
<div style={{
position: "absolute",
right: 10,
top: 10,
padding: "2px 8px",
borderRadius: 999,
color: "#fff",
fontSize: 12,
background: "rgba(0,0,0,0.45)",
}}>
{photoIndex + 1}/{selectedPhotos.length}
</div>
{photoHover && selectedPhotos.length > 1 && (
<>
<Button
shape="circle"
size="small"
icon={<LeftOutlined />}
onClick={(e) => {
e.stopPropagation();
setPhotoIndex((idx) => (idx - 1 + selectedPhotos.length) % selectedPhotos.length);
}}
style={{ position: "absolute", left: 10, top: "50%", transform: "translateY(-50%)", opacity: 0.92 }}
/>
<Button
shape="circle"
size="small"
icon={<RightOutlined />}
onClick={(e) => {
e.stopPropagation();
setPhotoIndex((idx) => (idx + 1) % selectedPhotos.length);
}}
style={{ position: "absolute", right: 10, top: "50%", transform: "translateY(-50%)", opacity: 0.92 }}
/>
</>
)}
</div>
</Descriptions.Item>
)}
<Descriptions.Item label="连接度">{selectedNode.value ?? 0}</Descriptions.Item>
{selectedDetailItems.map((item) => (
<Descriptions.Item key={item.key} label={item.label}>
{renderDetailValue(item)}
</Descriptions.Item>
))}
</>
)}
</Descriptions>
{selectedTrafficInfo && (
<Card size="small" title="交通可达">
<Space direction="vertical" style={{ width: "100%" }} size={8}>
<Space wrap>
<Tag color="gold">{selectedTrafficInfo.stations.length} 个附近站点</Tag>
<Tag color="purple">{selectedTrafficInfo.lines.length} 条公交线路</Tag>
</Space>
<div>
<div style={{ color: "#666", fontSize: 12, marginBottom: 4 }}>站点</div>
<Space wrap size={[4, 4]}>
{selectedTrafficInfo.stations.map((s) => (
<Tag key={s.id} style={{ background: colorOf(s.group), color: "#fff", borderColor: colorOf(s.group) }}>
{s.label || s.name}
</Tag>
))}
</Space>
</div>
<div>
<div style={{ color: "#666", fontSize: 12, marginBottom: 4 }}>线路</div>
<Space wrap size={[4, 4]}>
{selectedTrafficInfo.lines.map((l) => (
<Tag key={l.id} color="purple">{l.label || l.name}</Tag>
))}
</Space>
</div>
</Space>
</Card>
)}
{selectedBusLines.length > 0 && (
<Card size="small" title="经过线路">
<Space wrap size={[4, 4]}>
{selectedBusLines.map((line) => <Tag key={line} color="purple">{line}</Tag>)}
</Space>
</Card>
)}
</Space>
)}
</Drawer>
</div>
);
}