'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'; }