| | |
| | 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(); |
| |
|