1735 lines
77 KiB
TypeScript
1735 lines
77 KiB
TypeScript
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>
|
||
);
|
||
}
|