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 = { 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 = {}; 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 }; type GEdge = { id: string; from: string; to: string; label: string; properties?: Record }; 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 = {}, 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 { const groups = new Map(); 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(); 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(); 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 = { 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 { return ( sourceLabel(props.source_name) || sourceLabel(props.source) || sourceLabel(props.evidence_id) ); } function detailSourceUrl(props: Record = {}): string { const url = String(props.source_url || props.evidence_url || "").trim(); return /^https?:\/\//i.test(url) ? url : ""; } function detailUpdatedAt(props: Record = {}): { 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): 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, 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 = {}): 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 ( {value} ); } if (item.key === "source_url_display") { return ( {item.value} ); } 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 (
{lines.map((line, index) => { const isHeading = index === 0 && /[::]$/.test(line); return isHeading ? (
{line}
) : (
• {line}
); })}
); } } 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 {text}; } 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; edges: Map } ): { stations: GNode[]; lines: GNode[] } | null { if (!node || !["Place", "地点", "景点", "美食", "酒店", "商场"].includes(node.group)) return null; const stations = new Map(); const lines = new Map(); 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(null); const wrapRef = useRef(null); const chartInstance = useRef(null); // Accumulated graph data (Maps for dedupe + incremental expand) const dataRef = useRef<{ nodes: Map; edges: Map }>({ nodes: new Map(), edges: new Map(), }); const clickTimer = useRef | null>(null); const [loading, setLoading] = useState(false); const [cypher, setCypher] = useState(defaultCypherForGraph(getProjectContext().graphName)); const [overview, setOverview] = useState>({}); const [selectedNode, setSelectedNode] = useState(null); const [drawerOpen, setDrawerOpen] = useState(false); const [photoIndex, setPhotoIndex] = useState(0); const [photoHover, setPhotoHover] = useState(false); const [resultInfo, setResultInfo] = useState(""); const [queryMeta, setQueryMeta] = useState | 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 = {}; 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(); 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("
") : ""; return `${d.name}
类型: ${d.group} 度: ${d.value}
${props}
双击展开关联`; } 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 (

知识图谱浏览器

{/* Stats bar */} } valueStyle={{ fontSize: 20 }} /> } valueStyle={{ fontSize: 20 }} /> {displayLabelCounts.map((l: any) => ( {l.label}: {l.count} ))} {/* Query bar */} setCypher(e.target.value)} onSearch={(v) => runQuery(v)} placeholder={queryPlaceholder} style={{ minWidth: 480, flex: 1 }} loading={loading} enterButton={<> 智能查询} allowClear size="middle" /> 快速查询: {queryTemplates.map((t) => ( { setCypher(t.cypher); setTimeout(() => runQuery(t.cypher), 100); }} > {t.label} ))} {resultInfo && {resultInfo}} {isTravelGraph ? "默认展示固定线路主干;费用/规则/审计关系按需查询" : isYunyouLiboGraph ? "快速查询已按荔波县酒店、美食、交通、景点、公交和空间关系配置" : "支持自然语言和 Cypher"} · 单击看详情 · 双击展开关联 · 滚轮缩放 · 拖拽平移 {queryMeta?.cypher && (
{modeLabel(queryMeta.mode)} {queryMeta.cypher} {queryMeta.fallback_reason && ( 兜底原因:{queryMeta.fallback_reason} )}
)}
{/* Graph canvas — wrapRef is the fullscreen target */}
{/* Top-right toolbar */}
{loading && (
)}
{/* Relationship counts */} {overview.relationship_type_counts && ( {overview.relationship_type_counts.map((r: any) => ( {r.type}: {r.count} ))} )} {/* Node detail drawer */} {drawerTitle}} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520} > {selectedNode && ( {isTourProductDetail ? ( selectedDetailItems.map((item) => ( {renderDetailValue(item)} )) ) : ( <> {selectedNode.id} {drawerTitle} {selectedNode.group} {activePhoto && (
setPhotoHover(true)} onMouseLeave={() => setPhotoHover(false)} style={{ position: "relative", height: 220, overflow: "hidden", borderRadius: 6, background: "#f3f5f8", }} > {drawerTitle}
{photoIndex + 1}/{selectedPhotos.length}
{photoHover && selectedPhotos.length > 1 && ( <>
)} {selectedNode.value ?? 0} {selectedDetailItems.map((item) => ( {renderDetailValue(item)} ))} )}
{selectedTrafficInfo && ( {selectedTrafficInfo.stations.length} 个附近站点 {selectedTrafficInfo.lines.length} 条公交线路
站点
{selectedTrafficInfo.stations.map((s) => ( {s.label || s.name} ))}
线路
{selectedTrafficInfo.lines.map((l) => ( {l.label || l.name} ))}
)} {selectedBusLines.length > 0 && ( {selectedBusLines.map((line) => {line})} )}
)}
); }