'use strict';
import * as Vector from './js/vectorlib.js';
import * as Bezier from './js/bezierlib.js';
import * as Utils from './js/utillib.js';
import {Color} from './js/colorlib.js';
import * as Config from './config.js';
import * as MapData from './js/mapdata.js';
import * as Legend from './legend.js';
import * as Directions from './js/directions.js';
const mapContainer = document.getElementById('mapContainer');
const mapCanvas = document.getElementById('mapCanvas');
const dynCanvas = document.getElementById('dynCanvas');
/** The main map drawing context.
* @type {CanvasRenderingContext2D} */
const mapctx = mapCanvas.getContext('2d');
/** The dynamic map drawing context.
* @type {CanvasRenderingContext2D} */
const dynctx = dynCanvas.getContext('2d');
const mapSprites = document.getElementById('mapSprites');
const mapTerrain = document.getElementById('mapTerrain');
const zoomLevelDisplay = document.getElementById('zoomLevelDisplay');
let isNavigating = false;
document.addEventListener('DOMContentLoaded', async () => {
await loadShopData('shop_items.json');
await loadTrackData('trackdata_dv.json');
await loadPoiData('poi_dv.json');
await loadCatenaryData('catenary_dv.json');
MapData.sortMarkers();
Utils.generateGradeArrows();
MapData.matrix.initialize();
MapData.view.initialize();
Directions.initialize();
redrawMap();
mapNavigationSetup();
Legend.initialize();
tooltipSetup();
});
window.addEventListener('resize', () => {MapData.view.dirty = true});
/** Handling for the map scrolling and zooming */
function mapNavigationSetup(){
let touchCache = [];
let touchCount = 0;
let pinchDistance = null;
let previousScale = null;
const touchDownHandler = e => {
if(e.button != 0) return;
if(Directions.handlePointerDown(e)) return;
if(touchCache.length == 0){
document.addEventListener('pointermove', touchMoveHandler);
document.addEventListener('pointerup', touchUpHandler);
document.addEventListener('pointercancel', touchUpHandler);
//document.addEventListener('pointerout', touchUpHandler);
document.addEventListener('pointerleave', touchUpHandler);
}
touchCache[e.pointerId] = {x:e.clientX, y:e.clientY};
touchCount++;
if(touchCount == 2){
pinchDistance = getTouchDistance();
previousScale = MapData.view.scale;
}
mapContainer.style.cursor = 'grabbing';
}
const touchMoveHandler = e => {
if(Directions.handlePointerMove(e)) return;
if(!touchCache[e.pointerId]){
touchCache = [];
return;
}
if(touchCount == 1){
MapData.view.x += e.clientX - touchCache[e.pointerId].x;
MapData.view.y -= e.clientY - touchCache[e.pointerId].y;
}else if(touchCount == 2){
getTouchAverage();
zoomAtPosition(touchCenter_x, touchCenter_y, (previousScale * getTouchDistance()/pinchDistance)/MapData.view.scale);
}else{
touchCache = [];
MapData.view.dirty = true;
return;
}
isNavigating = true;
touchCache[e.pointerId].x = e.clientX;
touchCache[e.pointerId].y = e.clientY;
MapData.view.dirty = true;
}
const touchUpHandler = e => {
if(Directions.handlePointerUp(e)) return;
if(!touchCache[e.pointerId]) return;
touchCache[e.pointerId] = null;
touchCount--;
if(touchCount <= 0){
mapContainer.style.cursor = '';
}
}
const scrollHandler = e => {
if(e.deltaY != 0) zoomAtPosition(e.clientX, e.clientY, e.deltaY > 0 ? 1/Config.scrollZoomFactor : Config.scrollZoomFactor);
}
const navButtonInput = navType => {
const centerX = mapContainer.offsetWidth / 2;
const centerY = mapContainer.offsetHeight / 2;
switch(navType){
case 'zoomIn':
zoomAtPosition(centerX, centerY, Config.buttonZoomFactor);
break;
case 'zoomOut':
zoomAtPosition(centerX, centerY, 1/Config.buttonZoomFactor);
break;
}
}
const navUpdate = ts => {
if(ts && MapData.view.dirty){
redrawMap();
}
if(ts && MapData.view.dynDirty){
redrawDynamics();
}
requestAnimationFrame(navUpdate);
}
let touchDist_aX = 0;
let touchDist_aY = 0;
let touchDist_counter = 0;
const getTouchDistance = () => {
touchDist_counter = 0;
touchDist_aX = 0;
touchDist_aY = 0;
for(let touch of touchCache){
if(touchDist_counter == 0){
touchDist_aX += touch.x;
touchDist_aY += touch.y;
}else{
touchDist_aX -= touch.x;
touchDist_aY -= touch.y;
break;
}
touchDist_counter++;
}
return Math.sqrt(touchDist_aX*touchDist_aX + touchDist_aY*touchDist_aY);
};
let touchCenter_x = 0;
let touchCenter_y = 0;
let touchCenter_count = 0;
const getTouchAverage = () => {
touchCenter_x = 0;
touchCenter_y = 0;
touchCenter_count = 0;
for(let touch of touchCache){
touchCenter_x += touch.x;
touchCenter_y += touch.y;
touchCenter_count++;
}
touchCenter_x /= touchCenter_count;
touchCenter_y /= touchCenter_count;
}
let zoomCursorLocal_x = 0;
let zoomCursorLocal_y = 0;
const zoomAtPosition = (x, y, scaleFactor) => {
if(MapData.view.scale * scaleFactor > Config.maxZoomLevel || MapData.view.scale * scaleFactor < Config.minZoomLevel){
MapData.view.dirty = false;
return;
}
MapData.view.x = x - scaleFactor * (x - MapData.view.x);
MapData.view.y = -y - scaleFactor * (-y - MapData.view.y);
MapData.view.scale *= scaleFactor;
MapData.view.dirty = true;
}
mapContainer.addEventListener('pointerdown', touchDownHandler);
mapContainer.addEventListener('wheel', scrollHandler);
mapContainer.addEventListener('contextmenu', e => {
touchCache = {};
touchCount = 0;
});
document.getElementById('navZoomIn').addEventListener('click', e => {navButtonInput('zoomIn');});
document.getElementById('navZoomOut').addEventListener('click', e => {navButtonInput('zoomOut');});
navUpdate();
}
function tooltipSetup(){
const tooltip = document.getElementById('tooltip');
const cursorCoordDisplay = document.getElementById('cursorCoordDisplay');
let tooltipX = 0;
let tooltipY = 0;
let tooltipDirty = false;
let removeTooltip = false;
mapContainer.addEventListener('mousemove', event => {
tooltipX = event.clientX;
tooltipY = event.clientY;
removeTooltip = false;
tooltipDirty = true;
});
mapContainer.addEventListener('mouseleave', event => {
removeTooltip = true;
tooltipDirty = true;
});
const tooltipUpdate = ts => {
if(ts && tooltipDirty){
let tooltipContents = MapData.testTooltip(tooltipX, tooltipY);
if(tooltipContents != '' && !removeTooltip && !isNavigating){
tooltip.innerHTML = tooltipContents.replaceAll('\n','
');
let tooltipWidth = tooltip.clientWidth;
let tooltipHeight = tooltip.clientHeight;
tooltipX += 5;
tooltipY += 5;
if(tooltipWidth+tooltipX + 10 > mapContainer.clientWidth)
tooltipX -= tooltipWidth + 15;
if(tooltipHeight+tooltipY + 10 > mapContainer.clientHeight)
tooltipY -= tooltipHeight + 10;
tooltip.style.transform = `translate(${tooltipX}px, ${tooltipY}px)`;
tooltip.style.display = '';
mapCanvas.style.cursor = 'crosshair';
dynCanvas.style.cursor = 'crosshair';
}else{
tooltip.style.display = 'none';
mapCanvas.style.cursor = '';
dynCanvas.style.cursor = '';
}
isNavigating = false;
tooltipDirty = false;
}
cursorCoordDisplay.innerHTML = `X: ${MapData.view.unconvertX(tooltipX).toFixed(2)} / Z: ${MapData.view.unconvertY(tooltipY).toFixed(2)}`;
requestAnimationFrame(tooltipUpdate);
}
tooltipUpdate();
}
async function loadTrackData(file){
let mapData = await (await fetch(new Request(file))).json();
for(const trackItem of mapData){
switch(trackItem.type){
case 'bezier':
MapData.addTrack(trackItem);
break;
case 'junction':
MapData.addJunction(trackItem);
break;
default:
console.log(`Unknown type "${trackItem.type}"`);
break;
}
}
MapData.addPoi({type:'dummy_polyline', position:{x:0,y:0,z:0}});
MapData.sortTracks();
MapData.generateTrackSignage();
}
async function loadPoiData(file){
let poiData = await (await fetch(new Request(file))).json();
for(let poi of poiData){
MapData.addPoi(poi);
}
}
async function loadShopData(file) {
let shopData = await (await fetch(new Request(file))).json();
for(let item of shopData){
MapData.addShopItem(item);
}
MapData.sortShops();
}
async function loadCatenaryData(file) {
try {
let catData = await (await fetch(new Request(file))).json();
MapData.addCatenaryData(catData);
} catch(e) {
console.error('Failed to load catenary data:', e);
}
}
let railTrack = {};
let curve = {};
function redrawMap(){
MapData.view.pixelRatio = window.devicePixelRatio;
mapCanvas.width = mapContainer.clientWidth * MapData.view.pixelRatio;
mapCanvas.height = mapContainer.clientHeight * MapData.view.pixelRatio;
zoomLevelDisplay.innerHTML = `Zoom: ${(MapData.view.scale*MapData.view.pixelRatio).toFixed(3)}x / ${(1/(MapData.view.scale*MapData.view.pixelRatio)).toFixed(2)} m/px`;
if(MapData.layers.terrain){
mapctx.drawImage(
mapTerrain,
MapData.view.convertX(0),
MapData.view.convertY(16384),
16384 * MapData.view.scale * MapData.view.pixelRatio,
16384 * MapData.view.scale * MapData.view.pixelRatio
);
}
// Game gauge is 1.5 meter
const trackWidth = Math.min(Math.max(6, MapData.view.scale * 1.5), MapData.view.scale * 100) * MapData.view.pixelRatio;
mapctx.lineWidth = trackWidth;
for(const railTrack of MapData.railTracks){
for(const curve of railTrack.curves){
mapctx.beginPath();
mapctx.moveTo(MapData.view.convertX(curve.start.x), MapData.view.convertY(curve.start.z));
mapctx.bezierCurveTo(
MapData.view.convertX(curve.h1.x),
MapData.view.convertY(curve.h1.z),
MapData.view.convertX(curve.h2.x),
MapData.view.convertY(curve.h2.z),
MapData.view.convertX(curve.end.x),
MapData.view.convertY(curve.end.z)
);
mapctx.strokeStyle = curve.color;
mapctx.stroke();
}
}
drawCatenaryLayer(mapctx, trackWidth);
let curSprite;
let spriteSize;
let textMeasure;
let tempMeasure = {};
//Draw Static Markers
for(const marker of MapData.markers){
curSprite = null;
marker.visible = false;
if(!marker.ignoreCulling && (marker.hidden || (marker.minZoom && MapData.view.scale < marker.minZoom) || (marker.maxZoom && MapData.view.scale > marker.maxZoom))) continue;
let markerX = MapData.view.convertX(marker.position.x);
let markerY = MapData.view.convertY(marker.position.z);
if(!marker.ignoreCulling && (markerX > mapCanvas.width + Config.viewCullMargin || markerX < -Config.viewCullMargin || markerY > mapCanvas.height + Config.viewCullMargin || markerY < -Config.viewCullMargin)) continue; // View Culling
switch(marker.type){
case 'dummy_polyline':
Directions.drawPolyline(mapctx, trackWidth);
break;
case 'junction':
if(!(MapData.layers.junctions && MapData.layers.signage)) break;
marker.visible = true;
curSprite = Config.spriteBounds.junction;
spriteSize = 30;
break;
case 'grade':
if(!(MapData.layers.gradeArrows && MapData.layers.signage)) break;
marker.visible = true;
curSprite = Config.spriteBounds[`gradeArrow${MapData.layers.gradeDirection}_${marker.gradeClass}`];
spriteSize = 30;
mapctx.translate(markerX, markerY);
mapctx.rotate(marker.rotation);
mapctx.translate(-0.5*spriteSize * MapData.view.pixelRatio, -0.5*spriteSize * MapData.view.pixelRatio);
mapctx.drawImage(
Utils.gradeArrows,
curSprite.x,
curSprite.y,
curSprite.width,
curSprite.height,
0,
0,
spriteSize * MapData.view.pixelRatio,
spriteSize * MapData.view.pixelRatio
);
mapctx.resetTransform();
curSprite = null;
break;
case 'speed':
if(!(MapData.layers.speedSigns && MapData.layers.signage)) break;
marker.visible = true;
curSprite = Config.spriteBounds['speed_'+Math.floor(marker.value/10)];
spriteSize = 24;
break;
case 'service':
if(!(MapData.layers.services && MapData.layers.poi)) break;
marker.visible = true;
spriteSize = 30 * MapData.view.pixelRatio;
if(marker.serviceTypes.length == 0) marker.serviceTypes.push('');
if(marker.serviceTypes.length > 1){
mapctx.fillStyle = '#239a96';
tempMeasure.width = spriteSize * (marker.serviceTypes.length-1);
mapctx.fillRect(markerX - tempMeasure.width*0.5, markerY - spriteSize*0.5, tempMeasure.width, spriteSize);
}
for(let i=0; i= 10 || Math.abs(MapData.view.y - newY) >= 10) {
MapData.view.x = newX;
MapData.view.y = newY;
MapData.view.dirty = true;
}
}
if(marker.hidden || (marker.minZoom && MapData.view.scale < marker.minZoom) || (marker.maxZoom && MapData.view.scale > marker.maxZoom)) continue;
if(markerX > mapCanvas.width + Config.viewCullMargin || markerX < -Config.viewCullMargin || markerY > mapCanvas.height + Config.viewCullMargin || markerY < -Config.viewCullMargin) continue; // View Culling
switch(marker.type){
case 'player':
if(!(MapData.layers.dynamic && MapData.layers.player)) break;
marker.visible = true;
curSprite = Config.spriteBounds.player;
spriteSize = 45;
dynctx.translate(markerX, markerY);
dynctx.rotate(marker.rotation);
dynctx.translate(-0.5 * spriteSize * MapData.view.pixelRatio, -0.5 * spriteSize * MapData.view.pixelRatio);
dynctx.drawImage(
mapSprites,
curSprite.x,
curSprite.y,
curSprite.width,
curSprite.height,
0,
0,
spriteSize * MapData.view.pixelRatio,
spriteSize * MapData.view.pixelRatio
);
dynctx.resetTransform();
curSprite = null;
break;
case 'car':
if(!(MapData.layers.dynamic && MapData.layers.cars)) break;
if(!MapData.layers.otherCars && !marker.isPlayer) break;
let scale = MapData.view.pixelRatio * MapData.view.scale;
let limitScaleRatio = (MapData.view.pixelRatio * Math.max(MapData.view.scale, 3)) / scale;
let width = marker.width * scale;
let length = marker.length * scale;
marker.visible = true;
dynctx.beginPath();
dynctx.translate(markerX, markerY);
dynctx.rotate(marker.rotation);
dynctx.translate(-0.5 * width, -0.5 * length);
dynctx.rect(0, 0, width, length);
dynctx.moveTo(0, width);
dynctx.lineTo(0.5 * width, 0);
dynctx.lineTo(width, width);
dynctx.moveTo(0.5 * width, 0);
dynctx.arc(0.5 * width, 0.5 * length, 15, -0.5*Math.PI, 1.5*Math.PI);
dynctx.resetTransform();
dynctx.strokeStyle = marker.derailed ? '#f83' : '#fff';
dynctx.lineWidth = 0.75 * scale * limitScaleRatio;
dynctx.miterLimit = 1.5;
dynctx.stroke();
if(marker.derailed)
dynctx.fillStyle = marker.isLoco ? '#db4f18aa' : '#9e54d3aa';
else
dynctx.fillStyle = marker.isLoco ? '#e79830aa' : '#559bd4aa';
dynctx.fill();
dynctx.strokeStyle = marker.derailed ? '#410' : '#000';
dynctx.lineWidth = 0.3 * scale * limitScaleRatio;
dynctx.stroke();
if(!marker.tooltipHitzone){
marker.tooltipHitzone = {
radius: 15,
};
}
break;
default:
if(!MapData.layers.poi) break;
marker.visible = true;
curSprite = Config.spriteBounds.unknown;
spriteSize = 30;
break;
}
if(curSprite != null){
dynctx.drawImage(
mapSprites,
curSprite.x,
curSprite.y,
curSprite.width,
curSprite.height,
markerX-(0.5*spriteSize)*MapData.view.pixelRatio,
markerY-(0.5*spriteSize)*MapData.view.pixelRatio,
spriteSize*MapData.view.pixelRatio,
spriteSize*MapData.view.pixelRatio
);
}
}
MapData.view.dynDirty = false;
}
function drawCatenaryLayer(ctx, trackWidth) {
if(!MapData.layers.catenary) return;
const pixelRatio = MapData.view.pixelRatio;
const scale = MapData.view.scale;
// 1. Contact Wires & Neutral Lines (decoupled toggles)
if(MapData.catenaryPolylines && (MapData.layers.catenaryWires || MapData.layers.catenaryNeutrals)){
let minX = MapData.view.unconvertX(0) - Config.viewCullMargin / scale;
let maxX = MapData.view.unconvertX(mapCanvas.width) + Config.viewCullMargin / scale;
let minZ = MapData.view.unconvertY(mapCanvas.height) - Config.viewCullMargin / scale;
let maxZ = MapData.view.unconvertY(0) + Config.viewCullMargin / scale;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
for(const poly of MapData.catenaryPolylines){
if(poly.is_neutral){
if(!MapData.layers.catenaryNeutrals) continue;
} else {
if(!MapData.layers.catenaryWires) continue;
}
let b = poly.bounds;
if(b[2] < minX || b[0] > maxX || b[3] < minZ || b[1] > maxZ) continue;
let pts = poly.points;
if(!pts || pts.length < 2) continue;
ctx.beginPath();
ctx.moveTo(MapData.view.convertX(pts[0][0]), MapData.view.convertY(pts[0][1]));
for(let i = 1; i < pts.length; i++){
ctx.lineTo(MapData.view.convertX(pts[i][0]), MapData.view.convertY(pts[i][1]));
}
if(poly.is_neutral){
let w = Math.max(3.8 * pixelRatio, trackWidth * 0.75);
ctx.lineWidth = w;
ctx.setLineDash([]);
ctx.strokeStyle = '#ffffff';
ctx.stroke();
ctx.setLineDash([12 * pixelRatio, 12 * pixelRatio]);
ctx.strokeStyle = '#e63946';
ctx.stroke();
ctx.setLineDash([]);
} else if(poly.category === 'side_rail'){
ctx.lineWidth = Math.max(3.0 * pixelRatio, trackWidth * 0.55);
ctx.setLineDash([10 * pixelRatio, 5 * pixelRatio]);
ctx.strokeStyle = '#111111';
ctx.stroke();
ctx.lineWidth = Math.max(1.8 * pixelRatio, trackWidth * 0.35);
ctx.strokeStyle = poly.color;
ctx.stroke();
ctx.setLineDash([]);
} else if(poly.category === 'quad'){
ctx.lineWidth = Math.max(4.2 * pixelRatio, trackWidth * 0.85);
ctx.strokeStyle = '#111111';
ctx.stroke();
ctx.lineWidth = Math.max(2.4 * pixelRatio, trackWidth * 0.55);
ctx.strokeStyle = poly.color;
ctx.stroke();
} else if(poly.category === 'dual'){
ctx.lineWidth = Math.max(3.4 * pixelRatio, trackWidth * 0.7);
ctx.strokeStyle = poly.color;
ctx.stroke();
} else if(poly.category === 'trolley'){
ctx.lineWidth = Math.max(1.8 * pixelRatio, trackWidth * 0.4);
ctx.strokeStyle = poly.color;
ctx.stroke();
} else {
ctx.lineWidth = Math.max(2.4 * pixelRatio, trackWidth * 0.55);
ctx.strokeStyle = poly.color;
ctx.stroke();
}
}
}
// 2. Neutral Section Markers & Detail Signs
if(MapData.layers.catenaryNeutrals && MapData.catenaryNeutrals){
for(const n of MapData.catenaryNeutrals){
let nx = MapData.view.convertX(n.center.x);
let ny = MapData.view.convertY(n.center.z);
if(nx >= -50 && nx <= mapCanvas.width + 50 && ny >= -50 && ny <= mapCanvas.height + 50){
let badgeSize = 13 * pixelRatio;
ctx.beginPath();
ctx.arc(nx, ny, badgeSize, 0, Math.PI * 2);
ctx.fillStyle = '#e63946';
ctx.fill();
ctx.lineWidth = 2 * pixelRatio;
ctx.strokeStyle = '#ffffff';
ctx.stroke();
ctx.fillStyle = '#ffffff';
ctx.font = `bold ${10 * pixelRatio}px "Noto Sans"`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('0V', nx, ny);
if(scale >= 0.035){
ctx.font = `bold ${11 * pixelRatio}px "Noto Sans"`;
ctx.strokeStyle = '#000000';
ctx.lineWidth = 2.5 * pixelRatio;
ctx.strokeText(`⛔ ${n.id}`, nx, ny + 18 * pixelRatio);
ctx.fillStyle = '#ffcccc';
ctx.fillText(`⛔ ${n.id}`, nx, ny + 18 * pixelRatio);
}
}
// High zoom boundary signs (NeutralWarning, NeutralBegin, NeutralEnd)
if(scale >= 0.04 && n.signs){
for(const sign of n.signs){
let sx = MapData.view.convertX(sign.x);
let sy = MapData.view.convertY(sign.z);
if(sx < -20 || sx > mapCanvas.width + 20 || sy < -20 || sy > mapCanvas.height + 20) continue;
let isWarn = sign.type.includes('Warning');
let isBegin = sign.type.includes('Begin');
let signColor = isWarn ? '#f59e0b' : (isBegin ? '#e63946' : '#10b981');
let signSymbol = isWarn ? '⚠' : (isBegin ? '⛔' : '✓');
let r = 8 * pixelRatio;
ctx.beginPath();
ctx.arc(sx, sy, r, 0, Math.PI * 2);
ctx.fillStyle = signColor;
ctx.fill();
ctx.lineWidth = 1.2 * pixelRatio;
ctx.strokeStyle = '#ffffff';
ctx.stroke();
ctx.fillStyle = isWarn ? '#000000' : '#ffffff';
ctx.font = `bold ${8 * pixelRatio}px "Noto Sans"`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(signSymbol, sx, sy);
if(scale >= 0.08){
ctx.font = `${9 * pixelRatio}px "Noto Sans"`;
ctx.strokeStyle = '#000000';
ctx.lineWidth = 2 * pixelRatio;
ctx.strokeText(sign.type, sx, sy - 12 * pixelRatio);
ctx.fillStyle = '#ffffff';
ctx.fillText(sign.type, sx, sy - 12 * pixelRatio);
}
}
}
}
}
// 3. Interruption Markers
if(MapData.layers.catenaryInterruptions && MapData.catenaryInterruptions && scale >= 0.015){
for(const inter of MapData.catenaryInterruptions){
let ix = MapData.view.convertX(inter.center.x);
let iy = MapData.view.convertY(inter.center.z);
if(ix < -50 || ix > mapCanvas.width + 50 || iy < -50 || iy > mapCanvas.height + 50) continue;
let kind = inter.kind || 'drop';
let fillColor = kind === 'warning' ? '#f59e0b' : (kind === 'raise' ? '#10b981' : '#f97316');
let textColor = kind === 'warning' ? '#000000' : '#ffffff';
let icon = kind === 'warning' ? '⚠' : (kind === 'raise' ? '⤒' : '⤓');
let s = Math.max(7 * pixelRatio, Math.min(11 * pixelRatio, 220 * scale * pixelRatio));
ctx.beginPath();
ctx.moveTo(ix, iy - s);
ctx.lineTo(ix + s, iy);
ctx.lineTo(ix, iy + s);
ctx.lineTo(ix - s, iy);
ctx.closePath();
ctx.fillStyle = fillColor;
ctx.fill();
ctx.lineWidth = 1.5 * pixelRatio;
ctx.strokeStyle = '#000000';
ctx.stroke();
ctx.fillStyle = textColor;
ctx.font = `bold ${Math.round(s * 1.05)}px "Noto Sans"`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(icon, ix, iy);
if(scale >= 0.045){
let desc = kind === 'warning' ? '⚠ Разрыв КС' : (kind === 'raise' ? '⤒ Подъем пантографа' : '⤓ Опустить пантограф');
ctx.font = `bold ${10 * pixelRatio}px "Noto Sans"`;
ctx.strokeStyle = '#000000';
ctx.lineWidth = 2.5 * pixelRatio;
ctx.strokeText(desc, ix, iy + s + 7 * pixelRatio);
ctx.fillStyle = fillColor;
ctx.fillText(desc, ix, iy + s + 7 * pixelRatio);
}
}
}
// 4. Traction Substations
if(MapData.layers.catenarySubstations && MapData.catenarySubstations){
for(const sub of MapData.catenarySubstations){
let sx = MapData.view.convertX(sub.x);
let sy = MapData.view.convertY(sub.z);
if(sx < -80 || sx > mapCanvas.width + 80 || sy < -80 || sy > mapCanvas.height + 80) continue;
let radius = 14 * pixelRatio;
let subColor = sub.nominal_voltage === 1500 ? '#0077b6' : (sub.nominal_voltage === 1000 ? '#e85d04' : '#2b9348');
ctx.beginPath();
ctx.arc(sx, sy, radius + 2 * pixelRatio, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.fill();
ctx.beginPath();
ctx.arc(sx, sy, radius, 0, Math.PI * 2);
ctx.fillStyle = subColor;
ctx.fill();
ctx.lineWidth = 2 * pixelRatio;
ctx.strokeStyle = '#ffffff';
ctx.stroke();
ctx.fillStyle = '#ffe600';
ctx.font = `bold ${15 * pixelRatio}px "Noto Sans"`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('⚡', sx, sy - 1 * pixelRatio);
if(scale >= 0.025){
let labelText = `${sub.id} (${sub.nominal_voltage}V)`;
ctx.font = `bold ${11 * pixelRatio}px "Noto Sans"`;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.strokeStyle = '#000000';
ctx.lineWidth = 3 * pixelRatio;
ctx.strokeText(labelText, sx, sy + radius + 3 * pixelRatio);
ctx.fillStyle = '#ffffff';
ctx.fillText(labelText, sx, sy + radius + 3 * pixelRatio);
}
}
}
ctx.textAlign = 'start';
ctx.textBaseline = 'alphabetic';
}