| |
| class WorldMap { |
| constructor() { |
| |
| this.defaultMapData = { |
| places: ["Unknown"], |
| distances: [ |
| ] |
| }; |
|
|
| this.mapData = null; |
| this.selectedNode = null; |
| this.svg = null; |
| this.simulation = null; |
| this.container = null; |
| this.width = 0; |
| this.height = 0; |
|
|
| this.init(); |
| } |
|
|
| init() { |
| document.addEventListener('DOMContentLoaded', () => { |
| |
| this.initMapContainer(); |
|
|
| |
| window.addEventListener('resize', () => this.handleResize()); |
|
|
| |
| this.updateMap(this.defaultMapData); |
|
|
| |
| window.addEventListener('websocket-message', (event) => { |
| const message = event.detail; |
| if (message.type === 'initial_data' && message.data.map) { |
| this.updateMap(message.data.map); |
| } |
| }); |
| }); |
| } |
|
|
| initMapContainer() { |
| const container = document.getElementById('map-container'); |
| this.width = container.clientWidth; |
| this.height = container.clientHeight; |
|
|
| |
| const zoom = d3.zoom() |
| .scaleExtent([0.8, 2]) |
| .on("zoom", (event) => this.zoomed(event)); |
|
|
| |
| this.svg = d3.select("#map") |
| .append("svg") |
| .attr("width", this.width) |
| .attr("height", this.height) |
| .call(zoom); |
|
|
| |
| const backgroundLayer = this.svg.append("g") |
| .attr("class", "background-layer"); |
|
|
| |
| this.loadBackgroundImage("./frontend/assets/images/fantasy-map.png", backgroundLayer); |
|
|
| |
| this.container = this.svg.append("g") |
| .attr("class", "nodes-container"); |
| } |
|
|
| updateMap(mapData) { |
| this.mapData = mapData; |
|
|
| |
| const nodes = mapData.places.map(place => ({id: place})); |
| const links = mapData.distances.map(d => ({ |
| source: d.source, |
| target: d.target, |
| distance: d.distance |
| })); |
|
|
| |
| this.container.selectAll("*").remove(); |
|
|
| |
| this.simulation = d3.forceSimulation() |
| .force("link", d3.forceLink().id(d => d.id).distance(d => d.distance * 5)) |
| .force("charge", d3.forceManyBody().strength(-2000)) |
| .force("center", d3.forceCenter(this.width / 2, this.height / 2)) |
| .force("collision", d3.forceCollide().radius(40)); |
|
|
| |
| this.createLinks(links); |
| this.createNodes(nodes); |
|
|
| |
| this.simulation |
| .nodes(nodes) |
| .on("tick", () => this.ticked()); |
|
|
| this.simulation.force("link") |
| .links(links); |
| } |
|
|
| zoomed(event) { |
| this.container.attr("transform", event.transform); |
| } |
|
|
| createLinks(links) { |
| |
| this.link = this.container.append("g") |
| .selectAll("line") |
| .data(links) |
| .enter() |
| .append("line") |
| .attr("class", "link"); |
|
|
| |
| this.distanceLabels = this.container.append("g") |
| .selectAll("text") |
| .data(links) |
| .enter() |
| .append("text") |
| .attr("class", "distance-label") |
| .text(d => d.distance) |
| .attr("stroke", "white") |
| .attr("stroke-width", "2px") |
| .attr("paint-order", "stroke"); |
| } |
|
|
| createNodes(nodes) { |
| |
| this.node = this.container.append("g") |
| .selectAll(".node") |
| .data(nodes) |
| .enter() |
| .append("g") |
| .attr("class", "node") |
| .call(d3.drag() |
| .on("start", (event, d) => this.dragstarted(event, d)) |
| .on("drag", (event, d) => this.dragged(event, d)) |
| .on("end", (event, d) => this.dragended(event, d))) |
| .on("click", (event, d) => this.handleNodeClick(event, d)); |
|
|
| |
| this.node.append("circle") |
| .attr("r", 20); |
|
|
| |
| this.node.append("text") |
| .attr("text-anchor", "middle") |
| .attr("dominant-baseline", "middle") |
| .text(d => this.formatNodeName(d.id)); |
|
|
| this.node.append("title") |
| .text(d => d.id); |
|
|
| |
| if (!this.popup) { |
| this.popup = d3.select("body") |
| .append("div") |
| .attr("class", "popup") |
| .style("opacity", 0); |
| } |
|
|
| |
| d3.select("body").on("click", () => { |
| if (this.selectedNode) { |
| this.deselectNode(); |
| } |
| }); |
| } |
|
|
| ticked() { |
| this.link |
| .attr("x1", d => d.source.x) |
| .attr("y1", d => d.source.y) |
| .attr("x2", d => d.target.x) |
| .attr("y2", d => d.target.y); |
|
|
| this.distanceLabels |
| .attr("x", d => (d.source.x + d.target.x) / 2) |
| .attr("y", d => (d.source.y + d.target.y) / 2); |
|
|
| this.node |
| .attr("transform", d => `translate(${d.x},${d.y})`); |
| } |
|
|
| dragstarted(event, d) { |
| if (!event.active) this.simulation.alphaTarget(0.3).restart(); |
| d.fx = d.x; |
| d.fy = d.y; |
| } |
|
|
| dragged(event, d) { |
| const transform = d3.zoomTransform(this.svg.node()); |
| d.fx = (event.x - transform.x) / transform.k; |
| d.fy = (event.y - transform.y) / transform.k; |
| } |
|
|
| dragended(event, d) { |
| if (!event.active) this.simulation.alphaTarget(0); |
| d.fx = null; |
| d.fy = null; |
| } |
|
|
| handleNodeClick(event, d) { |
| event.stopPropagation(); |
|
|
| if (this.selectedNode === event.currentTarget) { |
| this.deselectNode(); |
| } else { |
| if (this.selectedNode) { |
| this.deselectNode(); |
| } |
|
|
| this.selectedNode = event.currentTarget; |
| |
| d3.select(this.selectedNode) |
| .select("circle") |
| .classed("selected", true) |
| .transition() |
| .duration(200) |
| .attr("r", 30); |
|
|
| this.link.transition() |
| .duration(200) |
| .style("stroke-opacity", l => |
| (l.source.id === d.id || l.target.id === d.id) ? 1 : 0.2 |
| ) |
| .style("stroke", l => |
| (l.source.id === d.id || l.target.id === d.id) ? "#ff0000" : "#999" |
| ); |
|
|
| this.popup.transition() |
| .duration(200) |
| .style("opacity", .9); |
| |
| this.popup.html(` |
| <h3>${d.id}</h3> |
| <p>连接数: ${this.getConnectedLinks(d.id).length}</p> |
| <p>相邻节点: ${this.getConnectedNodes(d.id).join(", ")}</p> |
| `) |
| .style("left", (event.pageX + 10) + "px") |
| .style("top", (event.pageY - 10) + "px"); |
| } |
| } |
|
|
| deselectNode() { |
| d3.select(this.selectedNode) |
| .select("circle") |
| .classed("selected", false) |
| .transition() |
| .duration(200) |
| .attr("r", 20); |
| |
| this.link.transition() |
| .duration(200) |
| .style("stroke-opacity", 0.6) |
| .style("stroke", "#999"); |
| |
| this.popup.transition() |
| .duration(200) |
| .style("opacity", 0); |
| |
| this.selectedNode = null; |
| } |
|
|
| formatNodeName(name, maxLength = 3) { |
| if (name.length <= maxLength) return name; |
| |
| if (/^[\u4e00-\u9fa5]+$/.test(name)) { |
| return name.substring(0, maxLength - 1) + '…'; |
| } else { |
| return name.substring(0, maxLength) + '...'; |
| } |
| } |
|
|
| getConnectedNodes(nodeId) { |
| return this.mapData.distances |
| .filter(l => l.source === nodeId || l.target === nodeId) |
| .map(l => l.source === nodeId ? l.target : l.source); |
| } |
|
|
| getConnectedLinks(nodeId) { |
| return this.mapData.distances |
| .filter(l => l.source === nodeId || l.target === nodeId); |
| } |
|
|
| loadBackgroundImage(url, backgroundLayer) { |
| const img = new Image(); |
| img.onload = () => { |
| const background = this.svg.append("image") |
| .attr("class", "map-background") |
| .attr("xlink:href", url) |
| .attr("width", this.width) |
| .attr("height", this.height); |
| backgroundLayer.append(() => background.node()); |
| }; |
| img.onerror = () => { |
| backgroundLayer.append("rect") |
| .attr("width", this.width) |
| .attr("height", this.height) |
| .attr("fill", "#f0f0f0"); |
| }; |
| img.src = url; |
| } |
|
|
| handleResize() { |
| const container = document.getElementById('map-container'); |
| this.width = container.clientWidth; |
| this.height = container.clientHeight; |
| |
| this.svg |
| .attr('width', this.width) |
| .attr('height', this.height); |
| |
| this.simulation.force('center', d3.forceCenter(this.width / 2, this.height / 2)); |
| this.simulation.alpha(0.3).restart(); |
| } |
| } |
|
|
| const worldMap = new WorldMap(); |
|
|