Compare commits
14
Commits
61281adf08
...
68d5058106
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68d5058106
|
||
|
|
8252fd6ba9 | ||
|
|
92b9ad2c1a | ||
|
|
8f7f5ae384 | ||
|
|
eed661e6d2 | ||
|
|
e7940ae0cb | ||
|
|
401dad3170 | ||
|
|
f4c1c27f21 | ||
|
|
dc6b7b42a4 | ||
|
|
77ec65a601 | ||
|
|
212c655b9d | ||
|
|
70943cf60d | ||
|
|
2d69836887 | ||
|
|
d7d15026d8 |
No files matched your search
@@ -0,0 +1,55 @@
|
|||||||
|
name: Deploy & Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
tags: [ 'v*' ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
|
||||||
|
- name: Validate Datasets & Syntax
|
||||||
|
run: |
|
||||||
|
node -e "JSON.parse(require('fs').readFileSync('catenary_dv.json')); JSON.parse(require('fs').readFileSync('trackdata_dv.json')); JSON.parse(require('fs').readFileSync('poi_dv.json')); console.log('Datasets valid!');"
|
||||||
|
node --check index.js
|
||||||
|
node --check legend.js
|
||||||
|
node --check config.js
|
||||||
|
node --check js/mapdata.js
|
||||||
|
echo "All JS syntax checks passed!"
|
||||||
|
|
||||||
|
- name: Upload Web Map Artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: dv-community-map-catenary
|
||||||
|
path: |
|
||||||
|
index.html
|
||||||
|
style.css
|
||||||
|
config.js
|
||||||
|
legend.js
|
||||||
|
index.js
|
||||||
|
mapSprites.svg
|
||||||
|
favicon.png
|
||||||
|
opengraph.png
|
||||||
|
catenary_dv.json
|
||||||
|
trackdata_dv.json
|
||||||
|
poi_dv.json
|
||||||
|
shop_items.json
|
||||||
|
js/
|
||||||
|
terrains/
|
||||||
|
|
||||||
|
- name: Create Gitea Release
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
uses: akkuman/gitea-release-action@v1
|
||||||
|
with:
|
||||||
|
files: |-
|
||||||
|
catenary_dv.json
|
||||||
|
trackdata_dv.json
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
name: Deploy to GitHub Pages
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pages: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: "pages"
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
environment:
|
||||||
|
name: github-pages
|
||||||
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Pages
|
||||||
|
uses: actions/configure-pages@v5
|
||||||
|
|
||||||
|
- name: Validate Datasets & Syntax
|
||||||
|
run: |
|
||||||
|
node -e "JSON.parse(require('fs').readFileSync('catenary_dv.json')); JSON.parse(require('fs').readFileSync('trackdata_dv.json')); JSON.parse(require('fs').readFileSync('poi_dv.json')); console.log('Datasets valid!');"
|
||||||
|
node --check index.js
|
||||||
|
node --check legend.js
|
||||||
|
node --check config.js
|
||||||
|
node --check js/mapdata.js
|
||||||
|
echo "Syntax check OK"
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-pages-artifact@v3
|
||||||
|
with:
|
||||||
|
path: '.'
|
||||||
|
|
||||||
|
- name: Deploy to GitHub Pages
|
||||||
|
id: deployment
|
||||||
|
uses: actions/deploy-pages@v4
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using DV.Customization;
|
using DV.Customization;
|
||||||
using DV.HUD;
|
using DV.HUD;
|
||||||
using DV.Simulation.Cars;
|
using DV.Simulation.Cars;
|
||||||
using DV.Utils;
|
using DV.Utils;
|
||||||
@@ -59,7 +59,7 @@ namespace CommunityMapLocation.Api.Location
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return new LocationResponse(PlayerManager.PlayerTransform, reverse)
|
return new LocationResponse(PlayerManager.PlayerTransform, rotationTransform, reverse)
|
||||||
{
|
{
|
||||||
Cars = relevantCars.Values.ToArray() ?? Array.Empty<CarLocationResponse>()
|
Cars = relevantCars.Values.ToArray() ?? Array.Empty<CarLocationResponse>()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using DV.OriginShift;
|
using DV.OriginShift;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
|
||||||
@@ -7,6 +7,11 @@ namespace CommunityMapLocation.Api.Location
|
|||||||
public class LocationResponse
|
public class LocationResponse
|
||||||
{
|
{
|
||||||
public LocationResponse(Transform playerTransform, bool reverse)
|
public LocationResponse(Transform playerTransform, bool reverse)
|
||||||
|
: this(playerTransform, playerTransform, reverse)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocationResponse(Transform playerTransform, Transform rotationTransform, bool reverse)
|
||||||
{
|
{
|
||||||
var position = playerTransform.AbsolutePosition();
|
var position = playerTransform.AbsolutePosition();
|
||||||
|
|
||||||
@@ -14,7 +19,8 @@ namespace CommunityMapLocation.Api.Location
|
|||||||
Y = position.y;
|
Y = position.y;
|
||||||
Z = position.z;
|
Z = position.z;
|
||||||
|
|
||||||
var angle = playerTransform.rotation.eulerAngles.y;
|
var targetTransform = rotationTransform != null ? rotationTransform : playerTransform;
|
||||||
|
var angle = targetTransform.rotation.eulerAngles.y;
|
||||||
|
|
||||||
if (reverse)
|
if (reverse)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,51 @@
|
|||||||
# [Unofficial Community Map for Derail Valley](https://pyronicampt.github.io/DV-Community-Map/)
|
# Derail Valley Community Map with Catenary DC Layer
|
||||||
A map to let you see the valley in more detail than the in-game map, featuring speed signage, grade indication, curve radius, etc.
|
|
||||||
|
|
||||||
## Current Plans
|
Interactive vector web map of Derail Valley railway network with DC electrification infrastructure overlay (Catenary-DC / WE6SIM mod).
|
||||||
- [ ] Generally improve the look and UX of the map
|
|
||||||
- [ ] Add Points of Interest, like Stations, service points, shop locations, and maybe even the potential spawn locations of wrecked demonstrators.
|
## Architecture & Stack
|
||||||
|
- **Frontend**: Vanilla ES6 modules, HTML5 Canvas 2D (double-buffered with dynamic overlay).
|
||||||
|
- **Coordinate System**: Game engine coordinates (meters), origin at bottom-left, mapped via `Matrix` and `View` transforms.
|
||||||
|
- **Datasets**:
|
||||||
|
- `catenary_dv.json`: 164 chained polylines (optimized from 3,751 segments), 9 traction substations, 5 neutral sections, 34 interruption clusters, 129 warning signs.
|
||||||
|
- `trackdata_dv.json`: Rail tracks (cubic Bézier curves) and junction points.
|
||||||
|
- `poi_dv.json`: Stations, offices, service tracks, turntables, landmarks.
|
||||||
|
- `shop_items.json`: Shop inventories and prices.
|
||||||
|
|
||||||
|
## Catenary Layer Features
|
||||||
|
- **Wire Suspension Rendering**:
|
||||||
|
- `Single` (6.0m, 1500V/750V): standard solid vector line.
|
||||||
|
- `Dual` (6.0m, 1500V): high-capacity twin catenary stroke.
|
||||||
|
- `Quad` (6.0m, 1500V): heavy 4-wire mountain pass stroke with dark border.
|
||||||
|
- `Trolley` (6.0m, 750V): fine yard wire.
|
||||||
|
- `Side Rail` (4.5m, 1000V): dashed 3rd contact rail at IME and SM mine tracks.
|
||||||
|
- `Neutral Sections` (0V): contrasting red-and-white dashed lines with `0V` danger badges.
|
||||||
|
- **Color Modes**:
|
||||||
|
- `Voltage`: 1500V DC (`#00d2ff`), 1000V DC (`#ff9f1c`), 750V DC (`#2ec4b6`), 0V Neutral (`#e63946`).
|
||||||
|
- `Substation`: Distinct color per feeding substation (CME/IME1500, CP750, FF1500, HB1500, IME1000, IMW1500, OR750, SM1500, SM1000).
|
||||||
|
- `Wire Type`: Color-coded by physical suspension category.
|
||||||
|
- **Markers & Tooltips**:
|
||||||
|
- Traction Substations: `⚡` badge with nominal voltage, supply voltage, max load (kW), and regenerative inverter presence.
|
||||||
|
- Neutral Sections: `⛔` badge with section ID, length, connecting substations, and disconnect traction warning.
|
||||||
|
- Interruption Signs: `⤓` diamond badge for pantograph drop/raise warnings.
|
||||||
|
- Contact Wires: Nearest-point hovering displaying voltage, substation feed, category, and segment length.
|
||||||
|
|
||||||
|
## CommunityMapLocation Mod Integration
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
1. **Rotation Desync**: `LocationResponse` accepts `rotationTransform` from locomotive transform or camera heading (fixing player orientation desync).
|
||||||
|
2. **URL Formatting**: Fixed string immutability bug in `address.replaceAll` inside `connectPlayerLocation`.
|
||||||
|
|
||||||
|
### CORS Configuration
|
||||||
|
In `<GameDirectory>/Mods/CommunityMapLocation/Settings.xml`:
|
||||||
|
```xml
|
||||||
|
<Settings>
|
||||||
|
<Port>9090</Port>
|
||||||
|
<RequestSourceType>Any</RequestSourceType>
|
||||||
|
<RequestSource>http://localhost:8080</RequestSource>
|
||||||
|
</Settings>
|
||||||
|
```
|
||||||
|
*Note: If `RequestSourceType` is `Any`, the game mod allows cross-origin requests from any web host (localhost, GitHub Pages, Gitea).*
|
||||||
|
|
||||||
|
## CI/CD Workflows
|
||||||
|
- **Gitea Actions** (`.gitea/workflows/deploy.yaml`): JSON dataset validation, JS syntax verification, artifact packaging with `actions/upload-artifact@v3`, releases with `akkuman/gitea-release-action@v1`.
|
||||||
|
- **GitHub Pages** (`.github/workflows/deploy-pages.yml`): Automated GitHub Pages deployment on push to `main`.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"metadata":{"source":"Derail Valley Catenary-DC / WE6SIM (1500 V DC catenary)","total_polylines":164,"total_wires":3751,"total_substations":9,"total_neutral_sections":5,"total_interruption_clusters":34,"total_signs":129,"voltage_levels":[1500,1000,750,0]},"substations":[{"id":"CME/IME1500","x":14880.059,"y":245.569641,"z":14040.495,"supply_voltage":1650.0,"nominal_voltage":1500,"maximum_load":5500.0,"has_inverter":true,"file":"scenery_CME-IME.json"},{"id":"CP750","x":2194.443,"y":145.997864,"z":8921.762,"supply_voltage":825.0,"nominal_voltage":750,"maximum_load":4000.0,"has_inverter":false,"file":"scenery_CP.json"},{"id":"FF1500","x":9335.659,"y":120.178413,"z":13298.051,"supply_voltage":1650.0,"nominal_voltage":1500,"maximum_load":4000.0,"has_inverter":true,"file":"scenery_FF.json"},{"id":"HB1500","x":12360.086,"y":113.61673,"z":3496.618,"supply_voltage":1650.0,"nominal_voltage":1500,"maximum_load":4500.0,"has_inverter":true,"file":"scenery_HB.json"},{"id":"IME1000","x":14971.501,"y":249.062012,"z":15327.436,"supply_voltage":1100.0,"nominal_voltage":1000,"maximum_load":2000.0,"has_inverter":false,"file":"scenery_IME.json"},{"id":"IMW1500","x":2333.273,"y":134.553085,"z":13127.537,"supply_voltage":1650.0,"nominal_voltage":1500,"maximum_load":4500.0,"has_inverter":true,"file":"scenery_IMW.json"},{"id":"OR750","x":6502.203,"y":144.807587,"z":11587.063,"supply_voltage":750.0,"nominal_voltage":750,"maximum_load":2000.0,"has_inverter":false,"file":"scenery_OR.json"},{"id":"SM1500","x":8430.701,"y":130.830338,"z":7929.717,"supply_voltage":1500.0,"nominal_voltage":1500,"maximum_load":4000.0,"has_inverter":true,"file":"scenery_SM.json"},{"id":"SM1000","x":8375.657,"y":130.941376,"z":7877.38,"supply_voltage":1100.0,"nominal_voltage":1000,"maximum_load":2000.0,"has_inverter":false,"file":"scenery_SM.json"}],"neutral_sections":[{"id":"[CP]![IMW]","file":"scenery_[CP]![IMW].json","center":{"x":1576.93,"z":10300.64},"bounds":[1526.39,10066.57,1655.5,10529.48],"total_length":485.42,"wire_count":12,"signs":[{"template":"Signs/NeutralBegin","type":"NeutralBegin","x":1527.21,"y":144.06,"z":10106.96,"file":"scenery_[CP]![IMW].json"},{"template":"Signs/NeutralEndLowVoltageOtherSide","type":"NeutralEndLowVoltageOtherSide","x":1527.23,"y":144.04,"z":10108.47,"file":"scenery_[CP]![IMW].json"},{"template":"Signs/NeutralWarning","type":"NeutralWarning","x":1558.15,"y":144.19,"z":9906.9,"file":"scenery_[CP]![IMW].json"},{"template":"Signs/NeutralEnd","type":"NeutralEnd","x":1645.83,"y":139.95,"z":10488.09,"file":"scenery_[CP]![IMW].json"},{"template":"Signs/NeutralBeginOtherSide","type":"NeutralBeginOtherSide","x":1646.46,"y":139.91,"z":10489.96,"file":"scenery_[CP]![IMW].json"},{"template":"Signs/NeutralWarningOtherSide","type":"NeutralWarningOtherSide","x":1662.85,"y":137.37,"z":10674.58,"file":"scenery_[CP]![IMW].json"}],"wires":[{"start":{"x":1645.83,"y":139.95,"z":10488.09},"end":{"x":1655.5,"y":139.95,"z":10529.48},"length":42.5,"wire_type":"end_anchor_single","category":"single","substation":"NEUTRAL","voltage":0,"file":"scenery_[CP]![IMW].json"},{"start":{"x":1631.98,"y":140.52,"z":10450.17},"end":{"x":1645.83,"y":140.52,"z":10488.1},"length":40.38,"wire_type":"plain_single","category":"single","substation":"NEUTRAL","voltage":0,"file":"scenery_[CP]![IMW].json"},{"start":{"x":1616.8,"y":141.09,"z":10414.4},"end":{"x":1631.98,"y":141.09,"z":10450.18},"length":38.87,"wire_type":"plain_single","category":"single","substation":"NEUTRAL","voltage":0,"file":"scenery_[CP]![IMW].json"},{"start":{"x":1600.29,"y":141.55,"z":10377.27},"end":{"x":1616.8,"y":141.55,"z":10414.4},"length":40.63,"wire_type":"plain_single","category":"single","substation":"NEUTRAL","voltage":0,"file":"scenery_[CP]![IMW].json"},{"start":{"x":1584.3,"y":142.11,"z":10340.1},"end":{"x":1600.29,"y":142.11,"z":10377.28},"length":40.47,"wire_type":"plain_single","category":"single","substation":"NEUTRAL","voltage":0,"file":"scenery_[CP]![IMW].json"},{"start":{"x":1567.91,"y":142.65,"z":10303.16},"end":{"x":1584.3,"y":142.65,"z":10340.1},"length":40.42,"wire_type":"plain_single","category":"single","substation":"NEUTRAL","voltage":0,"file":"scenery_[CP]![IMW].json"},{"start":{"x":1553.65,"y":143.1,"z":10265.41},"end":{"x":1567.91,"y":143.1,"z":10303.16},"length":40.35,"wire_type":"middle_anchor_single","category":"single","substation":"NEUTRAL","voltage":0,"file":"scenery_[CP]![IMW].json"},{"start":{"x":1541.91,"y":143.52,"z":10226.95},"end":{"x":1553.66,"y":143.52,"z":10265.41},"length":40.21,"wire_type":"plain_single","category":"single","substation":"NEUTRAL","voltage":0,"file":"scenery_[CP]![IMW].json"},{"start":{"x":1533.38,"y":143.76,"z":10187.23},"end":{"x":1541.91,"y":143.76,"z":10226.95},"length":40.63,"wire_type":"plain_single","category":"single","substation":"NEUTRAL","voltage":0,"file":"scenery_[CP]![IMW].json"},{"start":{"x":1528.89,"y":143.96,"z":10148.36},"end":{"x":1533.38,"y":143.96,"z":10187.23},"length":39.13,"wire_type":"plain_single","categoLine truncated
|
||||||
@@ -57,6 +57,23 @@ export const maxZoneLength = 700;
|
|||||||
/** How many pixels around the view is still considered "visible", beyond this and items are culled. */
|
/** How many pixels around the view is still considered "visible", beyond this and items are culled. */
|
||||||
export const viewCullMargin = 60;
|
export const viewCullMargin = 60;
|
||||||
|
|
||||||
|
export const routing = {
|
||||||
|
maxSpeed: 120,
|
||||||
|
maxGrade: 6,
|
||||||
|
gradePenalty: 0.04,
|
||||||
|
minGradeFactor: 0.35,
|
||||||
|
snapSearchSteps: 48,
|
||||||
|
polylineStepMeters: 25,
|
||||||
|
markerRadius: 10,
|
||||||
|
maxSnapDistance: 1200,
|
||||||
|
assumedUnpostedSpeed: 120,
|
||||||
|
unpostedSpeedLimit: 90,
|
||||||
|
tightCurveRadius: 170,
|
||||||
|
tightCurveSpeed: 40,
|
||||||
|
accel: 0.35,
|
||||||
|
decel: 0.5,
|
||||||
|
};
|
||||||
|
|
||||||
export const tooltipHitzone = {
|
export const tooltipHitzone = {
|
||||||
default: {radius:12},
|
default: {radius:12},
|
||||||
junction: {width:24, height:35},
|
junction: {width:24, height:35},
|
||||||
@@ -93,3 +110,32 @@ export const spriteBounds = {
|
|||||||
service_charger: new SpriteBounds(60,120,20,20),
|
service_charger: new SpriteBounds(60,120,20,20),
|
||||||
player: new SpriteBounds(20,0,20,20),
|
player: new SpriteBounds(20,0,20,20),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const catenaryVoltageColors = {
|
||||||
|
'1500': '#00d2ff',
|
||||||
|
'1000': '#ff9f1c',
|
||||||
|
'750': '#2ec4b6',
|
||||||
|
'0': '#e63946',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const catenarySubstationColors = {
|
||||||
|
'CME/IME1500': '#3a86ff',
|
||||||
|
'CP750': '#06d6a0',
|
||||||
|
'FF1500': '#8338ec',
|
||||||
|
'HB1500': '#ff006e',
|
||||||
|
'IME1000': '#fb5607',
|
||||||
|
'IMW1500': '#00b4d8',
|
||||||
|
'OR750': '#52b788',
|
||||||
|
'SM1500': '#ffd166',
|
||||||
|
'SM1000': '#f77f00',
|
||||||
|
'NEUTRAL': '#e63946',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const catenaryWireTypeColors = {
|
||||||
|
'single': '#48cae4',
|
||||||
|
'dual': '#0077b6',
|
||||||
|
'quad': '#7209b7',
|
||||||
|
'trolley': '#43aa8b',
|
||||||
|
'side_rail': '#f3722c',
|
||||||
|
'neutral': '#e63946',
|
||||||
|
};
|
||||||
+66
-7
@@ -14,6 +14,21 @@
|
|||||||
<script type="module" src="index.js"></script>
|
<script type="module" src="index.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<svg width="0" height="0" style="display: none;">
|
||||||
|
<symbol id="Info" fill="currentColor" viewBox="0 0 100 100">
|
||||||
|
<path d="M50 2C23 2 2 23 2 50 2 77 23 98 50 98 77 98 98 77 98 50 98 23 77 2 50 2Zm0 12C70 14 86 30 86 50 86 70 70 86 50 86 30 86 14 70 14 50 14 30 30 14 50 14Zm0 8c-2 0-3 0-5 1-1 1-2 3-2 4 0 2 1 4 2 4 1 1 2 1 5 1 2 0 3 0 5-1 1-1 2-3 2-4 0-2-1-4-2-4C53 22 52 22 50 22Zm-6 16V78H56V38Z"></path>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="Key" fill="currentColor" viewBox="0 0 100 100">
|
||||||
|
<path d="m3 71.3c0 12 9 22 22 22 12 0 21-10 21-22 0-4-1-8-3-12l18-21 10 8 5 4 8-9-5-4-10-9 6-7 11 10 5 4 8-9-5-4L74 5.3 34 51.3c-3-1-6-2-9-2-13 0-22 10-22 22zm12 0c0-5 4-10 10-10 5 0 9 5 9 10 0 6-4 10-9 10-6 0-10-4-10-10z"></path>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="Settings" fill="currentColor" viewBox="0 0 100 100">
|
||||||
|
<path d="M45 2C42 2 39 5 39 8L39 16C34 17 30 20 26 23L20 20C19 19 18 19 17 19 15 18 12 20 11 22L6 30C4 33 6 37 8 39L15 42C14 45 14 47 14 50 14 53 14 55 15 58L8 61C6 63 4 67 6 70L11 78C12 81 17 82 20 80L26 77C30 80 34 83 39 84L39 92C39 95 42 98 45 98L55 98C58 98 61 95 61 92L61 84C66 83 70 80 74 77L80 80C83 82 88 81 89 78L94 70C96 67 94 63 92 61L85 58C86 55 86 53 86 50 86 47 86 45 85 42L92 39C94 37 96 33 94 30L89 22C88 20 85 18 83 19 82 19 81 19 80 20L74 23C70 20 66 17 61 16L61 8C61 5 58 2 55 2L45 2zM50 28C62 28 72 38 72 50 72 62 62 72 50 72 38 72 28 62 28 50 28 38 38 28 50 28z"></path>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="Pathfinding" fill="currentColor" viewBox="0 0 100 100">
|
||||||
|
<path d="M62 5C55 5 46 8 36 12A7 7 0 0032 22 7 7 0 0042 25C54 19 62 19 66 19 71 20 71 21 71 23 71 28 64 40 45 43 21 46 9 65 15 80 18 88 25 94 34 95 43 96 54 93 67 85A7 7 0 0069 75 7 7 0 0059 73C48 80 41 82 36 81 31 80 29 78 28 76 26 70 29 59 47 57 71 53 86 38 85 23 84 15 78 8 69 6 67 5 65 5 62 5zM15 11C10 11 6 15 6 20 6 24 10 31 15 41 20 31 24 24 24 20 24 15 20 11 15 11zM84 53C80 53 76 57 76 61 76 66 79 73 84 82 90 73 93 66 93 61 93 57 89 53 84 53z"></path>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
|
||||||
<div id="mapContainer">
|
<div id="mapContainer">
|
||||||
<canvas id="mapCanvas"></canvas>
|
<canvas id="mapCanvas"></canvas>
|
||||||
<canvas id="dynCanvas"></canvas>
|
<canvas id="dynCanvas"></canvas>
|
||||||
@@ -27,7 +42,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="zoomLevelDisplay"></div>
|
<div id="zoomLevelDisplay"></div>
|
||||||
<div id="cursorCoordDisplay"></div>
|
<div id="cursorCoordDisplay"></div>
|
||||||
Map v0.6.6 alpha
|
Map v0.6.9 alpha
|
||||||
<br>
|
<br>
|
||||||
Updated for Game Build 99.7
|
Updated for Game Build 99.7
|
||||||
<br>
|
<br>
|
||||||
@@ -39,8 +54,7 @@
|
|||||||
<span id="redrawTrigger"></span>DV Community Map
|
<span id="redrawTrigger"></span>DV Community Map
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="legend">
|
<div id="sidepanel" style="visibility: hidden;">
|
||||||
<div id="legendContents">
|
|
||||||
<div class="panel linkpanel">
|
<div class="panel linkpanel">
|
||||||
<a href="https://github.com/PyroNicampt/DV-Community-Map">Source</a>
|
<a href="https://github.com/PyroNicampt/DV-Community-Map">Source</a>
|
||||||
<a href="https://github.com/PyroNicampt/DV-Community-Map/issues">Issue Tracker</a>
|
<a href="https://github.com/PyroNicampt/DV-Community-Map/issues">Issue Tracker</a>
|
||||||
@@ -48,32 +62,77 @@
|
|||||||
<a href="https://wiki.derailvalley.com/wiki/Main_Page">Official Manual</a>
|
<a href="https://wiki.derailvalley.com/wiki/Main_Page">Official Manual</a>
|
||||||
<a href="https://github.com/PyroNicampt/DV-Community-Map/releases">Location Mod</a>
|
<a href="https://github.com/PyroNicampt/DV-Community-Map/releases">Location Mod</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel">
|
<div id="tabContents">
|
||||||
|
<div data-tab="open">
|
||||||
|
<div class="tabContent">
|
||||||
Welcome to the WIP community map of <a href="https://www.derailvalley.com/" style="white-space:nowrap;">Derail Valley</a>, a resource for documenting and displaying all tracks and points of interest.
|
Welcome to the WIP community map of <a href="https://www.derailvalley.com/" style="white-space:nowrap;">Derail Valley</a>, a resource for documenting and displaying all tracks and points of interest.
|
||||||
<p>
|
<p>
|
||||||
Problems? Suggestions? Head over to <a href="https://github.com/PyroNicampt/DV-Community-Map/issues">the issue tracker</a> and see if it's already there, or submit if not. Or contribute a fix yourself if you really want!
|
Problems? Suggestions? Head over to <a href="https://github.com/PyroNicampt/DV-Community-Map/issues">the issue tracker</a> and see if it's already there, or submit if not. Or contribute a fix yourself if you really want!
|
||||||
</p>
|
</p>
|
||||||
|
<p>
|
||||||
|
Click the tabs to the left to view the Legend, Options, and more.
|
||||||
|
</p>
|
||||||
<hr style="margin: 5px 0;">
|
<hr style="margin: 5px 0;">
|
||||||
Hover over tracks and objects for detailed information.
|
Hover over tracks and objects for detailed information.
|
||||||
<hr style="margin: 5px 0;">
|
<hr style="margin: 5px 0;">
|
||||||
Last update <span id="lastUpdate"></span>:
|
Last update <span id="lastUpdate"></span>:
|
||||||
<div id="changelog"></div>
|
<div id="changelog"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel">
|
<div class="tabButton" title="Info">
|
||||||
|
<svg class="inlineSvg"><use href="#Info"></use></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="tabContent">
|
||||||
<h3>Key</h3>
|
<h3>Key</h3>
|
||||||
<hr>
|
<hr>
|
||||||
<div id="legendKey"></div>
|
<div id="legendKey"></div>
|
||||||
<hr>
|
<hr>
|
||||||
Track Colors:
|
Track Colors:
|
||||||
<div id="legendKeyColors"></div>
|
<div id="legendKeyColors"></div>
|
||||||
|
<hr>
|
||||||
|
Catenary Network:
|
||||||
|
<div id="legendCatenaryKey"></div>
|
||||||
|
<div id="legendCatenaryColors"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="settingsPanel" class="panel">
|
<div class="tabButton" title="Legend">
|
||||||
|
<svg class="inlineSvg"><use href="#Key"></use></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="directionsTab">
|
||||||
|
<div class="tabContent">
|
||||||
|
<h3>Pathfinding</h3>
|
||||||
|
<hr>
|
||||||
|
Click map to set stops.
|
||||||
|
<div id="directionsBody">
|
||||||
|
<div id="directionsStops"></div>
|
||||||
|
<div id="directionsControls">
|
||||||
|
<button id="directionsAddStop" class="directionButton">Add stop</button>
|
||||||
|
<button id="directionsClear" class="directionButton">Clear</button>
|
||||||
|
</div>
|
||||||
|
<div id="directionsOptions">
|
||||||
|
<label title="Caps the speed used for estimation and routing, even if the track limit is higher.">Max Speed <input id="routeMaxSpeed" type="number" min="10" max="200" step="5"></label>
|
||||||
|
<label title="Disallow routes with grades steeper than this percentage.">Max Grade <input id="routeMaxGrade" type="number" min="0" max="12" step="0.5"></label>
|
||||||
|
<label title="Penalizes speed as grade increases to simulate reduced climbing ability.">Grade Penalty <input id="routeGradePenalty" type="number" min="0" max="0.2" step="0.01"></label>
|
||||||
|
</div>
|
||||||
|
<div id="directionsSummary" class="quiet"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tabButton" title="Pathfinding">
|
||||||
|
<svg class="inlineSvg"><use href="#Pathfinding"></use></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div id="settingsPanel" class="tabContent">
|
||||||
<h3>Map Settings</h3>
|
<h3>Map Settings</h3>
|
||||||
<div class="quiet" style="margin: 0 0 5px 0;font-size: 10px;">(Page uses local-only cookies to keep settings)</div>
|
<div class="quiet" style="margin: 0 0 5px 0;font-size: 10px;">(Page uses local-only cookies to keep settings)</div>
|
||||||
<hr>
|
<hr>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="tabButton" title="Settings">
|
||||||
|
<svg class="inlineSvg"><use href="#Settings"></use></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button id="legendButton" class="panel orange"><svg style="transform:rotate(180deg) translateY(0px)" viewBox="0 0 32 32"><path fill="currentColor" d="M31 16 20 31l-16 0L15 16 4 1l16 0Z"/></svg></button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:none">
|
<div style="display:none">
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {Color} from './js/colorlib.js';
|
|||||||
import * as Config from './config.js';
|
import * as Config from './config.js';
|
||||||
import * as MapData from './js/mapdata.js';
|
import * as MapData from './js/mapdata.js';
|
||||||
import * as Legend from './legend.js';
|
import * as Legend from './legend.js';
|
||||||
|
import * as Directions from './js/directions.js';
|
||||||
|
|
||||||
const mapContainer = document.getElementById('mapContainer');
|
const mapContainer = document.getElementById('mapContainer');
|
||||||
const mapCanvas = document.getElementById('mapCanvas');
|
const mapCanvas = document.getElementById('mapCanvas');
|
||||||
@@ -27,12 +28,14 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
await loadShopData('shop_items.json');
|
await loadShopData('shop_items.json');
|
||||||
await loadTrackData('trackdata_dv.json');
|
await loadTrackData('trackdata_dv.json');
|
||||||
await loadPoiData('poi_dv.json');
|
await loadPoiData('poi_dv.json');
|
||||||
|
await loadCatenaryData('catenary_dv.json');
|
||||||
MapData.sortMarkers();
|
MapData.sortMarkers();
|
||||||
|
|
||||||
Utils.generateGradeArrows();
|
Utils.generateGradeArrows();
|
||||||
|
|
||||||
MapData.matrix.initialize();
|
MapData.matrix.initialize();
|
||||||
MapData.view.initialize();
|
MapData.view.initialize();
|
||||||
|
Directions.initialize();
|
||||||
redrawMap();
|
redrawMap();
|
||||||
mapNavigationSetup();
|
mapNavigationSetup();
|
||||||
Legend.initialize();
|
Legend.initialize();
|
||||||
@@ -49,6 +52,7 @@ function mapNavigationSetup(){
|
|||||||
let previousScale = null;
|
let previousScale = null;
|
||||||
const touchDownHandler = e => {
|
const touchDownHandler = e => {
|
||||||
if(e.button != 0) return;
|
if(e.button != 0) return;
|
||||||
|
if(Directions.handlePointerDown(e)) return;
|
||||||
if(touchCache.length == 0){
|
if(touchCache.length == 0){
|
||||||
document.addEventListener('pointermove', touchMoveHandler);
|
document.addEventListener('pointermove', touchMoveHandler);
|
||||||
|
|
||||||
@@ -66,6 +70,7 @@ function mapNavigationSetup(){
|
|||||||
mapContainer.style.cursor = 'grabbing';
|
mapContainer.style.cursor = 'grabbing';
|
||||||
}
|
}
|
||||||
const touchMoveHandler = e => {
|
const touchMoveHandler = e => {
|
||||||
|
if(Directions.handlePointerMove(e)) return;
|
||||||
if(!touchCache[e.pointerId]){
|
if(!touchCache[e.pointerId]){
|
||||||
touchCache = [];
|
touchCache = [];
|
||||||
return;
|
return;
|
||||||
@@ -87,6 +92,7 @@ function mapNavigationSetup(){
|
|||||||
MapData.view.dirty = true;
|
MapData.view.dirty = true;
|
||||||
}
|
}
|
||||||
const touchUpHandler = e => {
|
const touchUpHandler = e => {
|
||||||
|
if(Directions.handlePointerUp(e)) return;
|
||||||
if(!touchCache[e.pointerId]) return;
|
if(!touchCache[e.pointerId]) return;
|
||||||
touchCache[e.pointerId] = null;
|
touchCache[e.pointerId] = null;
|
||||||
touchCount--;
|
touchCount--;
|
||||||
@@ -172,6 +178,10 @@ function mapNavigationSetup(){
|
|||||||
|
|
||||||
mapContainer.addEventListener('pointerdown', touchDownHandler);
|
mapContainer.addEventListener('pointerdown', touchDownHandler);
|
||||||
mapContainer.addEventListener('wheel', scrollHandler);
|
mapContainer.addEventListener('wheel', scrollHandler);
|
||||||
|
mapContainer.addEventListener('contextmenu', e => {
|
||||||
|
touchCache = {};
|
||||||
|
touchCount = 0;
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById('navZoomIn').addEventListener('click', e => {navButtonInput('zoomIn');});
|
document.getElementById('navZoomIn').addEventListener('click', e => {navButtonInput('zoomIn');});
|
||||||
document.getElementById('navZoomOut').addEventListener('click', e => {navButtonInput('zoomOut');});
|
document.getElementById('navZoomOut').addEventListener('click', e => {navButtonInput('zoomOut');});
|
||||||
@@ -213,9 +223,11 @@ function tooltipSetup(){
|
|||||||
tooltip.style.transform = `translate(${tooltipX}px, ${tooltipY}px)`;
|
tooltip.style.transform = `translate(${tooltipX}px, ${tooltipY}px)`;
|
||||||
tooltip.style.display = '';
|
tooltip.style.display = '';
|
||||||
mapCanvas.style.cursor = 'crosshair';
|
mapCanvas.style.cursor = 'crosshair';
|
||||||
|
dynCanvas.style.cursor = 'crosshair';
|
||||||
}else{
|
}else{
|
||||||
tooltip.style.display = 'none';
|
tooltip.style.display = 'none';
|
||||||
mapCanvas.style.cursor = '';
|
mapCanvas.style.cursor = '';
|
||||||
|
dynCanvas.style.cursor = '';
|
||||||
}
|
}
|
||||||
isNavigating = false;
|
isNavigating = false;
|
||||||
tooltipDirty = false;
|
tooltipDirty = false;
|
||||||
@@ -243,6 +255,8 @@ async function loadTrackData(file){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MapData.addPoi({type:'dummy_polyline', position:{x:0,y:0,z:0}});
|
||||||
|
|
||||||
MapData.sortTracks();
|
MapData.sortTracks();
|
||||||
MapData.generateTrackSignage();
|
MapData.generateTrackSignage();
|
||||||
}
|
}
|
||||||
@@ -262,6 +276,15 @@ async function loadShopData(file) {
|
|||||||
MapData.sortShops();
|
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 railTrack = {};
|
||||||
let curve = {};
|
let curve = {};
|
||||||
@@ -300,6 +323,9 @@ function redrawMap(){
|
|||||||
mapctx.stroke();
|
mapctx.stroke();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
drawCatenaryLayer(mapctx, trackWidth);
|
||||||
|
|
||||||
let curSprite;
|
let curSprite;
|
||||||
let spriteSize;
|
let spriteSize;
|
||||||
let textMeasure;
|
let textMeasure;
|
||||||
@@ -308,11 +334,14 @@ function redrawMap(){
|
|||||||
for(const marker of MapData.markers){
|
for(const marker of MapData.markers){
|
||||||
curSprite = null;
|
curSprite = null;
|
||||||
marker.visible = false;
|
marker.visible = false;
|
||||||
if(marker.hidden || (marker.minZoom && MapData.view.scale < marker.minZoom) || (marker.maxZoom && MapData.view.scale > marker.maxZoom)) continue;
|
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 markerX = MapData.view.convertX(marker.position.x);
|
||||||
let markerY = MapData.view.convertY(marker.position.z);
|
let markerY = MapData.view.convertY(marker.position.z);
|
||||||
if(markerX > mapCanvas.width + Config.viewCullMargin || markerX < -Config.viewCullMargin || markerY > mapCanvas.height + Config.viewCullMargin || markerY < -Config.viewCullMargin) continue; // View Culling
|
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){
|
switch(marker.type){
|
||||||
|
case 'dummy_polyline':
|
||||||
|
Directions.drawPolyline(mapctx, trackWidth);
|
||||||
|
break;
|
||||||
case 'junction':
|
case 'junction':
|
||||||
if(!(MapData.layers.junctions && MapData.layers.signage)) break;
|
if(!(MapData.layers.junctions && MapData.layers.signage)) break;
|
||||||
marker.visible = true;
|
marker.visible = true;
|
||||||
@@ -483,6 +512,7 @@ function redrawMap(){
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Directions.drawMarkers(mapctx);
|
||||||
|
|
||||||
MapData.view.dirty = false;
|
MapData.view.dirty = false;
|
||||||
|
|
||||||
@@ -497,9 +527,24 @@ function redrawDynamics(){
|
|||||||
for(const marker of MapData.dynamicMarkers){
|
for(const marker of MapData.dynamicMarkers){
|
||||||
curSprite = null;
|
curSprite = null;
|
||||||
marker.visible = false;
|
marker.visible = false;
|
||||||
if(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 markerX = MapData.view.convertX(marker.position.x);
|
||||||
let markerY = MapData.view.convertY(marker.position.z);
|
let markerY = MapData.view.convertY(marker.position.z);
|
||||||
|
|
||||||
|
// Placing this here means the map should always snap to the player if the toggle is on.
|
||||||
|
// Placing it after culling means that if the user fast travels the map won't snap to them until manually scrolled to near their position.
|
||||||
|
if(MapData.layers.gps && marker.type === 'player') {
|
||||||
|
|
||||||
|
// These functions are very closely related to convertX and convertY
|
||||||
|
let newX = -marker.position.x * MapData.view.scale + mapCanvas.width * 0.5;
|
||||||
|
let newY = ((MapData.matrix.minY+MapData.matrix.maxY-marker.position.z) * MapData.view.scale - mapCanvas.height * 0.5);
|
||||||
|
if(Math.abs(MapData.view.x - newX) >= 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
|
if(markerX > mapCanvas.width + Config.viewCullMargin || markerX < -Config.viewCullMargin || markerY > mapCanvas.height + Config.viewCullMargin || markerY < -Config.viewCullMargin) continue; // View Culling
|
||||||
switch(marker.type){
|
switch(marker.type){
|
||||||
case 'player':
|
case 'player':
|
||||||
@@ -587,3 +632,180 @@ function redrawDynamics(){
|
|||||||
}
|
}
|
||||||
MapData.view.dynDirty = false;
|
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
|
||||||
|
if(MapData.layers.catenaryWires && MapData.catenaryPolylines){
|
||||||
|
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){
|
||||||
|
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.5 * pixelRatio, trackWidth * 0.7);
|
||||||
|
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(2.5 * pixelRatio, trackWidth * 0.45);
|
||||||
|
ctx.setLineDash([8 * pixelRatio, 4 * pixelRatio]);
|
||||||
|
ctx.strokeStyle = poly.color;
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
} else if(poly.category === 'quad'){
|
||||||
|
ctx.lineWidth = Math.max(4 * pixelRatio, trackWidth * 0.85);
|
||||||
|
ctx.strokeStyle = '#111111';
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.lineWidth = Math.max(2.2 * pixelRatio, trackWidth * 0.55);
|
||||||
|
ctx.strokeStyle = poly.color;
|
||||||
|
ctx.stroke();
|
||||||
|
} else if(poly.category === 'dual'){
|
||||||
|
ctx.lineWidth = Math.max(3.2 * 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
|
||||||
|
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) continue;
|
||||||
|
|
||||||
|
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.04){
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Interruption Markers
|
||||||
|
if(MapData.layers.catenaryInterruptions && MapData.catenaryInterruptions){
|
||||||
|
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 s = 10 * 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 = '#ff9f1c';
|
||||||
|
ctx.fill();
|
||||||
|
ctx.lineWidth = 1.5 * pixelRatio;
|
||||||
|
ctx.strokeStyle = '#000000';
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.fillStyle = '#000000';
|
||||||
|
ctx.font = `bold ${11 * pixelRatio}px "Noto Sans"`;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText('⤓', ix, iy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.03){
|
||||||
|
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';
|
||||||
|
}
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
import * as MapData from './mapdata.js';
|
||||||
|
import * as Pathfinder from './pathfinder.js';
|
||||||
|
import * as Config from './../config.js';
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
graph: null,
|
||||||
|
points: [],
|
||||||
|
route: null,
|
||||||
|
addMode: false,
|
||||||
|
draggingIndex: null,
|
||||||
|
draggingPointerId: null,
|
||||||
|
dragQueued: false,
|
||||||
|
ui: {},
|
||||||
|
options: {
|
||||||
|
maxSpeed: Config.routing.maxSpeed,
|
||||||
|
maxGrade: Config.routing.maxGrade,
|
||||||
|
gradePenalty: Config.routing.gradePenalty,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const markerColors = {
|
||||||
|
start: '#35c45a',
|
||||||
|
via: '#3b82f6',
|
||||||
|
end: '#ef4444',
|
||||||
|
};
|
||||||
|
|
||||||
|
function mapPositionFromEvent(e){
|
||||||
|
return {
|
||||||
|
x: MapData.view.unconvertX(e.clientX),
|
||||||
|
y: 0,
|
||||||
|
z: MapData.view.unconvertY(e.clientY),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapPosition(position){
|
||||||
|
if(!state.graph) return position;
|
||||||
|
const snap = Pathfinder.snapToNetwork(state.graph, position, state.options);
|
||||||
|
if(!snap) return position;
|
||||||
|
return snap.point;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPointAt(index, position){
|
||||||
|
state.points[index] = {
|
||||||
|
position: snapPosition(position),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPoint(position, insertBeforeEnd = false){
|
||||||
|
const snapped = snapPosition(position);
|
||||||
|
if(insertBeforeEnd && state.points.length >= 2){
|
||||||
|
state.points.splice(state.points.length - 1, 0, {position: snapped});
|
||||||
|
}else{
|
||||||
|
state.points.push({position: snapped});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPoints(){
|
||||||
|
state.points = [];
|
||||||
|
state.route = null;
|
||||||
|
renderStops();
|
||||||
|
renderSummary('Click the map to set a start point.');
|
||||||
|
MapData.view.dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopLabel(index){
|
||||||
|
return String.fromCharCode(65 + index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRole(index){
|
||||||
|
if(index === 0) return 'Start';
|
||||||
|
if(index === state.points.length - 1) return 'End';
|
||||||
|
return 'Stop';
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopColor(index){
|
||||||
|
if(index === 0) return markerColors.start;
|
||||||
|
if(index === state.points.length - 1) return markerColors.end;
|
||||||
|
return markerColors.via;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPosition(pos){
|
||||||
|
return `X ${pos.x.toFixed(1)}, Z ${pos.z.toFixed(1)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStops(){
|
||||||
|
const list = state.ui.stops;
|
||||||
|
if(!list) return;
|
||||||
|
list.innerHTML = '';
|
||||||
|
|
||||||
|
const total = Math.max(state.points.length, 2);
|
||||||
|
for(let i=0; i<total; i++){
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'directionRow';
|
||||||
|
|
||||||
|
const badge = document.createElement('div');
|
||||||
|
badge.className = 'directionBadge';
|
||||||
|
badge.style.background = (i === 0) ? markerColors.start : (i === total - 1 ? markerColors.end : markerColors.via);
|
||||||
|
badge.textContent = stopLabel(i);
|
||||||
|
row.appendChild(badge);
|
||||||
|
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.className = 'directionInput';
|
||||||
|
input.readOnly = true;
|
||||||
|
if(state.points[i]){
|
||||||
|
input.value = formatPosition(state.points[i].position);
|
||||||
|
}else{
|
||||||
|
input.placeholder = i === 0 ? 'Click map to set start' : 'Click map to set end';
|
||||||
|
}
|
||||||
|
row.appendChild(input);
|
||||||
|
|
||||||
|
if(i > 0 && i < total - 1 && state.points[i]){
|
||||||
|
const remove = document.createElement('button');
|
||||||
|
remove.className = 'directionButton';
|
||||||
|
remove.textContent = 'Remove';
|
||||||
|
remove.addEventListener('click', () => {
|
||||||
|
state.points.splice(i, 1);
|
||||||
|
scheduleRouteUpdate();
|
||||||
|
renderStops();
|
||||||
|
});
|
||||||
|
row.appendChild(remove);
|
||||||
|
}
|
||||||
|
|
||||||
|
list.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
state.ui.addStop.disabled = state.points.length < 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSummary(text){
|
||||||
|
if(!state.ui.summary) return;
|
||||||
|
state.ui.summary.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds){
|
||||||
|
if(!Number.isFinite(seconds)) return '-';
|
||||||
|
const mins = Math.round(seconds / 60);
|
||||||
|
if(mins < 60) return `${mins} min`;
|
||||||
|
const hours = Math.floor(mins / 60);
|
||||||
|
const rem = mins % 60;
|
||||||
|
return `${hours} h ${rem} m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRoute(){
|
||||||
|
if(!state.graph || state.points.length < 2){
|
||||||
|
state.route = null;
|
||||||
|
renderSummary('Click the map to set a start and end point.');
|
||||||
|
MapData.view.dirty = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let combinedPolyline = [];
|
||||||
|
let combinedEdges = [];
|
||||||
|
|
||||||
|
for(let i=0; i<state.points.length - 1; i++){
|
||||||
|
const start = state.points[i].position;
|
||||||
|
const end = state.points[i+1].position;
|
||||||
|
const result = Pathfinder.findRoute(state.graph, start, end, state.options);
|
||||||
|
if(!result){
|
||||||
|
state.route = null;
|
||||||
|
renderSummary('No route found with current limits.');
|
||||||
|
MapData.view.dirty = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
combinedEdges = combinedEdges.concat(result.edges);
|
||||||
|
|
||||||
|
if(combinedPolyline.length > 0 && result.polyline.length > 0){
|
||||||
|
const last = combinedPolyline[combinedPolyline.length - 1];
|
||||||
|
const first = result.polyline[0];
|
||||||
|
if(last.x === first.x && last.z === first.z){
|
||||||
|
combinedPolyline = combinedPolyline.concat(result.polyline.slice(1));
|
||||||
|
}else{
|
||||||
|
combinedPolyline = combinedPolyline.concat(result.polyline);
|
||||||
|
}
|
||||||
|
}else{
|
||||||
|
combinedPolyline = combinedPolyline.concat(result.polyline);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = Pathfinder.computeRouteStats(combinedEdges, state.options);
|
||||||
|
state.route = {
|
||||||
|
polyline: combinedPolyline,
|
||||||
|
stats,
|
||||||
|
};
|
||||||
|
|
||||||
|
const distanceKm = (stats.length / 1000).toFixed(2);
|
||||||
|
const duration = formatDuration(stats.time);
|
||||||
|
state.ui.summary.innerHTML = [
|
||||||
|
`Distance ${distanceKm} km`,
|
||||||
|
`<span title="Estimated travel time with acceleration and braking between speed changes.">Est. ${duration}</span>`,
|
||||||
|
`<span title="Steepest grade encountered along the route.">Max grade ${stats.maxGrade.toFixed(1)}%</span>`,
|
||||||
|
`<span title="Highest effective speed used in estimation after applying limits, grade penalty, and curve caps.">Max speed ${Math.round(stats.maxSpeed)} km/h</span>`,
|
||||||
|
`<span title="Highest posted track speed limit encountered (with realistic caps for unposted sections).">Max speed limit ${Math.round(stats.maxSpeedLimit)} km/h</span>`,
|
||||||
|
].join(' | ');
|
||||||
|
MapData.view.dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleRouteUpdate(){
|
||||||
|
if(state.dragQueued) return;
|
||||||
|
state.dragQueued = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
state.dragQueued = false;
|
||||||
|
updateRoute();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function drawPolyline(ctx, trackWidth){
|
||||||
|
if(!state.route || !state.route.polyline || state.route.polyline.length < 2) return;
|
||||||
|
ctx.save();
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
const width = trackWidth * 1.3;
|
||||||
|
const outline = trackWidth * 1.3 + 5;
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
for(let i=0; i<state.route.polyline.length; i++){
|
||||||
|
const p = state.route.polyline[i];
|
||||||
|
const x = MapData.view.convertX(p.x);
|
||||||
|
const y = MapData.view.convertY(p.z);
|
||||||
|
if(i === 0) ctx.moveTo(x, y);
|
||||||
|
else ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
ctx.strokeStyle = '#0b122088';
|
||||||
|
ctx.lineWidth = outline;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.strokeStyle = '#4fa3ffdd';
|
||||||
|
ctx.lineWidth = width;
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function drawMarkers(ctx){
|
||||||
|
const radius = Config.routing.markerRadius * MapData.view.pixelRatio;
|
||||||
|
ctx.save();
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.font = `bold ${10 * MapData.view.pixelRatio}px "Noto Sans"`;
|
||||||
|
for(let i=0; i<state.points.length; i++){
|
||||||
|
const pos = state.points[i].position;
|
||||||
|
const x = MapData.view.convertX(pos.x);
|
||||||
|
const y = MapData.view.convertY(pos.z);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = stopColor(i);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = '#0b0f1a';
|
||||||
|
ctx.lineWidth = 2 * MapData.view.pixelRatio;
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.fillStyle = '#0b0f1a';
|
||||||
|
ctx.fillText(stopLabel(i), x, y + 0.5 * MapData.view.pixelRatio);
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMapPointerUp(e){
|
||||||
|
if(e.button !== 0) return;
|
||||||
|
if(state.draggingIndex != null) return;
|
||||||
|
if(!state.ui.tab.dataset.tab) return;
|
||||||
|
const down = state.ui.lastPointerDown;
|
||||||
|
if(!down) return;
|
||||||
|
const dx = e.clientX - down.x;
|
||||||
|
const dy = e.clientY - down.y;
|
||||||
|
const distance = Math.sqrt(dx*dx + dy*dy);
|
||||||
|
if(distance > 6) return;
|
||||||
|
|
||||||
|
const position = mapPositionFromEvent(e);
|
||||||
|
if(state.points.length === 0){
|
||||||
|
addPoint(position);
|
||||||
|
}else if(state.points.length === 1){
|
||||||
|
addPoint(position);
|
||||||
|
}else if(state.addMode){
|
||||||
|
addPoint(position, true);
|
||||||
|
state.addMode = false;
|
||||||
|
state.ui.addStop.classList.remove('active');
|
||||||
|
}else{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderStops();
|
||||||
|
scheduleRouteUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initialize(){
|
||||||
|
state.graph = Pathfinder.buildGraph(MapData.railTracks);
|
||||||
|
|
||||||
|
state.ui.tab = document.getElementById('directionsTab');
|
||||||
|
state.ui.stops = document.getElementById('directionsStops');
|
||||||
|
state.ui.summary = document.getElementById('directionsSummary');
|
||||||
|
state.ui.addStop = document.getElementById('directionsAddStop');
|
||||||
|
state.ui.clear = document.getElementById('directionsClear');
|
||||||
|
state.ui.maxSpeed = document.getElementById('routeMaxSpeed');
|
||||||
|
state.ui.maxGrade = document.getElementById('routeMaxGrade');
|
||||||
|
state.ui.gradePenalty = document.getElementById('routeGradePenalty');
|
||||||
|
state.ui.toggle = document.getElementById('directionsToggle');
|
||||||
|
|
||||||
|
state.ui.maxSpeed.value = state.options.maxSpeed;
|
||||||
|
state.ui.maxGrade.value = state.options.maxGrade;
|
||||||
|
state.ui.gradePenalty.value = state.options.gradePenalty;
|
||||||
|
|
||||||
|
state.ui.maxSpeed.addEventListener('change', e => {
|
||||||
|
state.options.maxSpeed = Math.max(10, Number(e.target.value) || Config.routing.maxSpeed);
|
||||||
|
scheduleRouteUpdate();
|
||||||
|
});
|
||||||
|
state.ui.maxGrade.addEventListener('change', e => {
|
||||||
|
state.options.maxGrade = Math.max(0, Number(e.target.value) || Config.routing.maxGrade);
|
||||||
|
scheduleRouteUpdate();
|
||||||
|
});
|
||||||
|
state.ui.gradePenalty.addEventListener('input', e => {
|
||||||
|
state.options.gradePenalty = Math.max(0, Number(e.target.value) || 0);
|
||||||
|
scheduleRouteUpdate();
|
||||||
|
});
|
||||||
|
|
||||||
|
state.ui.addStop.addEventListener('click', () => {
|
||||||
|
if(state.points.length < 2) return;
|
||||||
|
state.addMode = !state.addMode;
|
||||||
|
state.ui.addStop.classList.toggle('active', state.addMode);
|
||||||
|
});
|
||||||
|
|
||||||
|
state.ui.clear.addEventListener('click', () => {
|
||||||
|
state.addMode = false;
|
||||||
|
state.ui.addStop.classList.remove('active');
|
||||||
|
clearPoints();
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapContainer = document.getElementById('mapContainer');
|
||||||
|
mapContainer.addEventListener('pointerdown', e => {
|
||||||
|
state.ui.lastPointerDown = {x:e.clientX, y:e.clientY};
|
||||||
|
});
|
||||||
|
mapContainer.addEventListener('pointerup', handleMapPointerUp);
|
||||||
|
|
||||||
|
renderStops();
|
||||||
|
renderSummary('Click the map to set a start point.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hitTestMarker(e){
|
||||||
|
if(state.points.length === 0) return null;
|
||||||
|
const radius = (Config.routing.markerRadius + 6) * MapData.view.pixelRatio;
|
||||||
|
const x = e.clientX * MapData.view.pixelRatio;
|
||||||
|
const y = e.clientY * MapData.view.pixelRatio;
|
||||||
|
for(let i=0; i<state.points.length; i++){
|
||||||
|
const pos = state.points[i].position;
|
||||||
|
const sx = MapData.view.convertX(pos.x);
|
||||||
|
const sy = MapData.view.convertY(pos.z);
|
||||||
|
const dx = sx - x;
|
||||||
|
const dy = sy - y;
|
||||||
|
if(dx*dx + dy*dy <= radius*radius) return i;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handlePointerDown(e){
|
||||||
|
if(e.button !== 0) return false;
|
||||||
|
const hit = hitTestMarker(e);
|
||||||
|
if(hit == null) return false;
|
||||||
|
state.draggingIndex = hit;
|
||||||
|
state.draggingPointerId = e.pointerId;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handlePointerMove(e){
|
||||||
|
if(state.draggingIndex == null) return false;
|
||||||
|
if(state.draggingPointerId !== e.pointerId) return false;
|
||||||
|
const position = mapPositionFromEvent(e);
|
||||||
|
setPointAt(state.draggingIndex, position);
|
||||||
|
scheduleRouteUpdate();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handlePointerUp(e){
|
||||||
|
if(state.draggingIndex == null) return false;
|
||||||
|
if(state.draggingPointerId !== e.pointerId) return false;
|
||||||
|
state.draggingIndex = null;
|
||||||
|
state.draggingPointerId = null;
|
||||||
|
scheduleRouteUpdate();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
+203
-7
@@ -88,11 +88,30 @@ export let view = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export let layers = {};
|
export let layers = {
|
||||||
|
catenary: true,
|
||||||
|
catenaryWires: true,
|
||||||
|
catenarySubstations: true,
|
||||||
|
catenaryNeutrals: true,
|
||||||
|
catenaryInterruptions: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export let catenaryData = null;
|
||||||
|
export let catenaryPolylines = [];
|
||||||
|
export let catenarySubstations = [];
|
||||||
|
export let catenaryNeutrals = [];
|
||||||
|
export let catenaryInterruptions = [];
|
||||||
|
export let catenarySigns = [];
|
||||||
|
export let catenaryColorMode = 'Voltage';
|
||||||
|
|
||||||
export function reset(){
|
export function reset(){
|
||||||
railTracks = [];
|
railTracks = [];
|
||||||
markers = [];
|
markers = [];
|
||||||
|
catenaryPolylines = [];
|
||||||
|
catenarySubstations = [];
|
||||||
|
catenaryNeutrals = [];
|
||||||
|
catenaryInterruptions = [];
|
||||||
|
catenarySigns = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sortTracks(){
|
export function sortTracks(){
|
||||||
@@ -108,7 +127,6 @@ export function sortMarkers(){
|
|||||||
}
|
}
|
||||||
|
|
||||||
function markerSortFunction(a, b){
|
function markerSortFunction(a, b){
|
||||||
(a,b) => {
|
|
||||||
if(a.type == 'player') return 1;
|
if(a.type == 'player') return 1;
|
||||||
if(b.type == 'player') return -1;
|
if(b.type == 'player') return -1;
|
||||||
if(a.type == b.type){
|
if(a.type == b.type){
|
||||||
@@ -123,6 +141,8 @@ function markerSortFunction(a, b){
|
|||||||
if(b.type == 'station') return -1;
|
if(b.type == 'station') return -1;
|
||||||
if(a.type == 'speed') return 1;
|
if(a.type == 'speed') return 1;
|
||||||
if(b.type == 'speed') return -1;
|
if(b.type == 'speed') return -1;
|
||||||
|
if(a.type == 'dummy_polyline') return 1;
|
||||||
|
if(b.type == 'dummy_polyline') return -1;
|
||||||
if(a.type == 'junction') return 1;
|
if(a.type == 'junction') return 1;
|
||||||
if(b.type == 'junction') return -1;
|
if(b.type == 'junction') return -1;
|
||||||
if(a.type == 'service') return 1;
|
if(a.type == 'service') return 1;
|
||||||
@@ -132,7 +152,6 @@ function markerSortFunction(a, b){
|
|||||||
if(b.type == 'demonstratorSpawnHint') return 1;
|
if(b.type == 'demonstratorSpawnHint') return 1;
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
export function sortShops(){
|
export function sortShops(){
|
||||||
for(let shop in Shops){
|
for(let shop in Shops){
|
||||||
@@ -308,6 +327,10 @@ export function addPoi(poiData, level = 0){
|
|||||||
newPoi.radius = poiData.radius;
|
newPoi.radius = poiData.radius;
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
|
if(poiData.type.startsWith('dummy_')){
|
||||||
|
newPoi.ignoreCulling = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
newPoi.tooltip = `<h1>${newPoi.name}</h1>${poiData.description ? '<hr>'+poiData.description : ''}`;
|
newPoi.tooltip = `<h1>${newPoi.name}</h1>${poiData.description ? '<hr>'+poiData.description : ''}`;
|
||||||
newPoi.minZoom = 0.07;
|
newPoi.minZoom = 0.07;
|
||||||
break;
|
break;
|
||||||
@@ -568,6 +591,56 @@ export function testTooltip(cursorX, cursorY){
|
|||||||
}
|
}
|
||||||
if(finalTooltip != '') return finalTooltip;
|
if(finalTooltip != '') return finalTooltip;
|
||||||
|
|
||||||
|
if(layers.catenary && layers.catenarySubstations){
|
||||||
|
for(const sub of catenarySubstations){
|
||||||
|
let radius = 24 / view.scale;
|
||||||
|
if(Math.pow(sub.x - cursorX, 2) + Math.pow(sub.z - cursorY, 2) <= radius * radius){
|
||||||
|
return sub.tooltip;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(layers.catenary && layers.catenaryNeutrals){
|
||||||
|
for(const n of catenaryNeutrals){
|
||||||
|
let radius = 35 / view.scale;
|
||||||
|
if(Math.pow(n.center.x - cursorX, 2) + Math.pow(n.center.z - cursorY, 2) <= radius * radius){
|
||||||
|
return n.tooltip;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(layers.catenary && layers.catenaryInterruptions){
|
||||||
|
for(const inter of catenaryInterruptions){
|
||||||
|
let radius = 24 / view.scale;
|
||||||
|
if(Math.pow(inter.center.x - cursorX, 2) + Math.pow(inter.center.z - cursorY, 2) <= radius * radius){
|
||||||
|
return inter.tooltip;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(layers.catenary && layers.catenaryWires){
|
||||||
|
let bestWireDistSq = Infinity;
|
||||||
|
let wireTooltip = '';
|
||||||
|
let wireBoundsPadding = 25 / view.scale;
|
||||||
|
for(const poly of catenaryPolylines){
|
||||||
|
let b = poly.bounds;
|
||||||
|
if(cursorX >= b[0] - wireBoundsPadding && cursorX <= b[2] + wireBoundsPadding && cursorY >= b[1] - wireBoundsPadding && cursorY <= b[3] + wireBoundsPadding){
|
||||||
|
let pts = poly.points;
|
||||||
|
for(let i = 0; i < pts.length - 1; i++){
|
||||||
|
let d2 = distToSegmentSquared(cursorX, cursorY, pts[i][0], pts[i][1], pts[i+1][0], pts[i+1][1]);
|
||||||
|
if(d2 < bestWireDistSq){
|
||||||
|
bestWireDistSq = d2;
|
||||||
|
wireTooltip = poly.tooltip;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let maxWireDist = Math.min(Math.max(1.2, 12 / view.scale), 80);
|
||||||
|
if(bestWireDistSq <= maxWireDist * maxWireDist){
|
||||||
|
return wireTooltip;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let bestDistance = Infinity;
|
let bestDistance = Infinity;
|
||||||
let test = {};
|
let test = {};
|
||||||
let boundsPadding = 40/view.scale;
|
let boundsPadding = 40/view.scale;
|
||||||
@@ -680,16 +753,139 @@ export function setTrackColorMode(mode){
|
|||||||
view.dirty = true;
|
view.dirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function distToSegmentSquared(px, pz, x1, z1, x2, z2) {
|
||||||
|
let dx = x2 - x1;
|
||||||
|
let dz = z2 - z1;
|
||||||
|
let l2 = dx * dx + dz * dz;
|
||||||
|
if (l2 === 0) {
|
||||||
|
let ex = px - x1;
|
||||||
|
let ez = pz - z1;
|
||||||
|
return ex * ex + ez * ez;
|
||||||
|
}
|
||||||
|
let t = ((px - x1) * dx + (pz - z1) * dz) / l2;
|
||||||
|
t = Math.max(0, Math.min(1, t));
|
||||||
|
let nx = x1 + t * dx;
|
||||||
|
let nz = z1 + t * dz;
|
||||||
|
let rx = px - nx;
|
||||||
|
let rz = pz - nz;
|
||||||
|
return rx * rx + rz * rz;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addCatenaryData(data){
|
||||||
|
catenaryData = data;
|
||||||
|
catenaryPolylines = data.polylines || [];
|
||||||
|
catenarySubstations = data.substations || [];
|
||||||
|
catenaryNeutrals = data.neutral_sections || [];
|
||||||
|
catenaryInterruptions = data.interruptions || [];
|
||||||
|
catenarySigns = data.signs || [];
|
||||||
|
|
||||||
|
for(const sub of catenarySubstations){
|
||||||
|
sub.tooltip = `<h1>⚡ Тяговая подстанция ${sub.id}</h1>` +
|
||||||
|
`Номинальное напряжение: ${sub.nominal_voltage} В DC\n` +
|
||||||
|
`Питающее напряжение: ${sub.supply_voltage} В DC\n` +
|
||||||
|
`Максимальная мощность: ${sub.maximum_load} кВт\n` +
|
||||||
|
`Инвертор рекуперации: ${sub.has_inverter ? 'Есть (возврат энергии в сеть)' : 'Нет'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
for(const n of catenaryNeutrals){
|
||||||
|
n.tooltip = `<h1>⛔ Токораздел (Нейтральная вставка): ${n.id}</h1>` +
|
||||||
|
`Напряжение: 0 В (Обесточенный участок)\n` +
|
||||||
|
`Длина вставки: ${n.total_length} м\n` +
|
||||||
|
`Проводов: ${n.wire_count}\n` +
|
||||||
|
`Знаки: ${(n.signs || []).map(s => s.type).join(', ')}\n` +
|
||||||
|
`Внимание: Отключить тягу перед прохождением!`;
|
||||||
|
}
|
||||||
|
|
||||||
|
for(const inter of catenaryInterruptions){
|
||||||
|
inter.tooltip = `<h1>⤓ Прерывание контактной сети</h1>` +
|
||||||
|
`Знаки: ${(inter.signs || []).join(', ')}\n` +
|
||||||
|
`Внимание: Опустить токоприемники (Drop Pantographs)!`;
|
||||||
|
}
|
||||||
|
|
||||||
|
for(const p of catenaryPolylines){
|
||||||
|
let catDesc = 'Одинарная подвеска (Single 6.0м)';
|
||||||
|
if(p.category === 'dual') catDesc = 'Двойная подвеска (Dual 6.0м, повышенная токоотдача)';
|
||||||
|
else if(p.category === 'quad') catDesc = 'Усиленная 4-проводная (Quad 6.0м, горный перевал)';
|
||||||
|
else if(p.category === 'trolley') catDesc = 'Трамвайная/простая подвеска (Trolley 6.0м, маневровые)';
|
||||||
|
else if(p.category === 'side_rail') catDesc = 'Боковой 3-й контактный рельс (Side Rail 4.5м, шахты)';
|
||||||
|
|
||||||
|
p.tooltip = `<h1>⚡ Контактная сеть: ${p.is_neutral ? 'Токораздел (0 В)' : p.voltage + ' В DC'}</h1>` +
|
||||||
|
`Напряжение: ${p.voltage} В DC\n` +
|
||||||
|
`Тип подвески: ${catDesc}\n` +
|
||||||
|
`Тяговая подстанция: ${p.substation}\n` +
|
||||||
|
`Длина участка: ${p.length} м (${p.wire_count} звеньев)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCatenaryColorMode(catenaryColorMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCatenaryColorMode(mode){
|
||||||
|
catenaryColorMode = mode;
|
||||||
|
for(const p of catenaryPolylines){
|
||||||
|
if(p.is_neutral){
|
||||||
|
p.color = '#e63946';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
switch(mode){
|
||||||
|
case 'Substation':
|
||||||
|
p.color = Config.catenarySubstationColors[p.substation] || '#3a86ff';
|
||||||
|
break;
|
||||||
|
case 'Wire Type':
|
||||||
|
p.color = Config.catenaryWireTypeColors[p.category] || '#48cae4';
|
||||||
|
break;
|
||||||
|
case 'Voltage':
|
||||||
|
default:
|
||||||
|
p.color = Config.catenaryVoltageColors[String(p.voltage)] || '#00d2ff';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateCatenaryLegend();
|
||||||
|
view.dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateCatenaryLegend(){
|
||||||
|
const elem = document.getElementById('legendCatenaryColors');
|
||||||
|
if(!elem) return;
|
||||||
|
elem.innerHTML = '';
|
||||||
|
switch(catenaryColorMode){
|
||||||
|
case 'Voltage':
|
||||||
|
elem.innerHTML = `
|
||||||
|
<span class="colorKey"><span style="background:${Config.catenaryVoltageColors['1500']}"></span> 1500V DC (Магистраль)</span>
|
||||||
|
<span class="colorKey"><span style="background:${Config.catenaryVoltageColors['1000']}"></span> 1000V DC (Боковой рельс)</span>
|
||||||
|
<span class="colorKey"><span style="background:${Config.catenaryVoltageColors['750']}"></span> 750V DC (Троллей)</span>
|
||||||
|
<span class="colorKey"><span style="background:${Config.catenaryVoltageColors['0']}"></span> 0V (Токораздел)</span>
|
||||||
|
`;
|
||||||
|
break;
|
||||||
|
case 'Substation':
|
||||||
|
let subHtml = '';
|
||||||
|
for(const [subId, col] of Object.entries(Config.catenarySubstationColors)){
|
||||||
|
subHtml += `<span class="colorKey"><span style="background:${col}"></span> ${subId}</span> `;
|
||||||
|
}
|
||||||
|
elem.innerHTML = subHtml;
|
||||||
|
break;
|
||||||
|
case 'Wire Type':
|
||||||
|
elem.innerHTML = `
|
||||||
|
<span class="colorKey"><span style="background:${Config.catenaryWireTypeColors['single']}"></span> Single (6.0м)</span>
|
||||||
|
<span class="colorKey"><span style="background:${Config.catenaryWireTypeColors['dual']}"></span> Dual (6.0м)</span>
|
||||||
|
<span class="colorKey"><span style="background:${Config.catenaryWireTypeColors['quad']}"></span> Quad (Перевал 6.0м)</span>
|
||||||
|
<span class="colorKey"><span style="background:${Config.catenaryWireTypeColors['trolley']}"></span> Trolley (6.0м)</span>
|
||||||
|
<span class="colorKey"><span style="background:${Config.catenaryWireTypeColors['side_rail']}"></span> 3-й рельс (4.5м)</span>
|
||||||
|
`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function setLocationUpdateRate(rate){
|
export function setLocationUpdateRate(rate){
|
||||||
locationUpdateRate = rate;
|
locationUpdateRate = rate;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function connectPlayerLocation(address, status){
|
export function connectPlayerLocation(address, status){
|
||||||
if(!address.startsWith('http://')){
|
let cleanAddress = (address || '').trim().replace(/^https?:\/\//i, '').replace(/\/+$/, '');
|
||||||
address.replaceAll(/.*:\/\//g, '');
|
cleanAddress = 'http://' + cleanAddress;
|
||||||
address = 'http://' + address;
|
if(!cleanAddress.endsWith('/location')){
|
||||||
|
cleanAddress += '/location';
|
||||||
}
|
}
|
||||||
address += '/location';
|
address = cleanAddress;
|
||||||
console.log(`Connecting to game at ${address}`);
|
console.log(`Connecting to game at ${address}`);
|
||||||
let started = false;
|
let started = false;
|
||||||
const endConnect = () => {
|
const endConnect = () => {
|
||||||
|
|||||||
@@ -0,0 +1,484 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
import * as Bezier from './bezierlib.js';
|
||||||
|
import * as Utils from './utillib.js';
|
||||||
|
import * as Config from './../config.js';
|
||||||
|
|
||||||
|
const defaults = {
|
||||||
|
maxSpeed: Config.routing.maxSpeed,
|
||||||
|
maxGrade: Config.routing.maxGrade,
|
||||||
|
gradePenalty: Config.routing.gradePenalty,
|
||||||
|
minGradeFactor: Config.routing.minGradeFactor,
|
||||||
|
snapSearchSteps: Config.routing.snapSearchSteps,
|
||||||
|
polylineStepMeters: Config.routing.polylineStepMeters,
|
||||||
|
maxSnapDistance: Config.routing.maxSnapDistance,
|
||||||
|
assumedUnpostedSpeed: Config.routing.assumedUnpostedSpeed,
|
||||||
|
unpostedSpeedLimit: Config.routing.unpostedSpeedLimit,
|
||||||
|
tightCurveRadius: Config.routing.tightCurveRadius,
|
||||||
|
tightCurveSpeed: Config.routing.tightCurveSpeed,
|
||||||
|
accel: Config.routing.accel,
|
||||||
|
decel: Config.routing.decel,
|
||||||
|
};
|
||||||
|
|
||||||
|
function distance2D(a, b){
|
||||||
|
const dx = a.x - b.x;
|
||||||
|
const dz = a.z - b.z;
|
||||||
|
return Math.sqrt(dx*dx + dz*dz);
|
||||||
|
}
|
||||||
|
|
||||||
|
function distance3D(a, b){
|
||||||
|
const dx = a.x - b.x;
|
||||||
|
const dy = (a.y ?? 0) - (b.y ?? 0);
|
||||||
|
const dz = a.z - b.z;
|
||||||
|
return Math.sqrt(dx*dx + dy*dy + dz*dz);
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundsDistance2D(bounds, point){
|
||||||
|
let dx = 0;
|
||||||
|
let dz = 0;
|
||||||
|
if(point.x < bounds.min.x) dx = bounds.min.x - point.x;
|
||||||
|
else if(point.x > bounds.max.x) dx = point.x - bounds.max.x;
|
||||||
|
if(point.z < bounds.min.z) dz = bounds.min.z - point.z;
|
||||||
|
else if(point.z > bounds.max.z) dz = point.z - bounds.max.z;
|
||||||
|
return Math.sqrt(dx*dx + dz*dz);
|
||||||
|
}
|
||||||
|
|
||||||
|
function approximateCurveLength(curve, t0, t1, steps){
|
||||||
|
if(t0 === t1) return 0;
|
||||||
|
const start = Math.min(t0, t1);
|
||||||
|
const end = Math.max(t0, t1);
|
||||||
|
const segments = Math.max(2, steps ?? 16);
|
||||||
|
let length = 0;
|
||||||
|
let prev = Bezier.evaluatePoint(curve.start, curve.h1, curve.h2, curve.end, start);
|
||||||
|
for(let i=1; i<=segments; i++){
|
||||||
|
const t = Utils.lerp(start, end, i/segments);
|
||||||
|
const cur = Bezier.evaluatePoint(curve.start, curve.h1, curve.h2, curve.end, t);
|
||||||
|
length += distance3D(prev, cur);
|
||||||
|
prev = cur;
|
||||||
|
}
|
||||||
|
return length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildGraph(railTracks, precision = 2){
|
||||||
|
const nodes = [];
|
||||||
|
const edges = [];
|
||||||
|
const curves = [];
|
||||||
|
const nodeIndex = new Map();
|
||||||
|
|
||||||
|
const keyFor = pos => `${pos.x.toFixed(precision)}|${pos.y.toFixed(precision)}|${pos.z.toFixed(precision)}`;
|
||||||
|
|
||||||
|
const getNodeId = pos => {
|
||||||
|
const key = keyFor(pos);
|
||||||
|
let id = nodeIndex.get(key);
|
||||||
|
if(id == null){
|
||||||
|
id = nodes.length;
|
||||||
|
nodeIndex.set(key, id);
|
||||||
|
nodes.push({
|
||||||
|
id,
|
||||||
|
position: {x:pos.x, y:pos.y, z:pos.z},
|
||||||
|
});
|
||||||
|
edges[id] = [];
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const addEdge = (from, to, edge) => {
|
||||||
|
edges[from].push(edge);
|
||||||
|
};
|
||||||
|
|
||||||
|
for(const railTrack of railTracks){
|
||||||
|
for(const curve of railTrack.curves){
|
||||||
|
const startId = getNodeId(curve.start);
|
||||||
|
const endId = getNodeId(curve.end);
|
||||||
|
curve._routeStartId = startId;
|
||||||
|
curve._routeEndId = endId;
|
||||||
|
curve._routeTrackName = railTrack.name;
|
||||||
|
const edgeBase = {
|
||||||
|
curve,
|
||||||
|
length: curve.length,
|
||||||
|
grade: curve.grade,
|
||||||
|
speed: curve.postedSpeed,
|
||||||
|
};
|
||||||
|
addEdge(startId, endId, {
|
||||||
|
...edgeBase,
|
||||||
|
to: endId,
|
||||||
|
t0: 0,
|
||||||
|
t1: 1,
|
||||||
|
});
|
||||||
|
addEdge(endId, startId, {
|
||||||
|
...edgeBase,
|
||||||
|
to: startId,
|
||||||
|
t0: 1,
|
||||||
|
t1: 0,
|
||||||
|
});
|
||||||
|
curves.push(curve);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {nodes, edges, curves};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function snapToNetwork(graph, position, options = {}){
|
||||||
|
const opts = {...defaults, ...options};
|
||||||
|
let best = null;
|
||||||
|
let bestDist = Infinity;
|
||||||
|
let bestT = 0;
|
||||||
|
let bestPoint = null;
|
||||||
|
for(const curve of graph.curves){
|
||||||
|
const boundDist = boundsDistance2D(curve.bounds, position);
|
||||||
|
if(boundDist > bestDist) continue;
|
||||||
|
const steps = opts.snapSearchSteps;
|
||||||
|
let localBestDist = Infinity;
|
||||||
|
let localBestT = 0;
|
||||||
|
let localBestPoint = null;
|
||||||
|
for(let i=0; i<=steps; i++){
|
||||||
|
const t = i/steps;
|
||||||
|
const p = Bezier.evaluatePoint(curve.start, curve.h1, curve.h2, curve.end, t);
|
||||||
|
const d = distance2D(p, position);
|
||||||
|
if(d < localBestDist){
|
||||||
|
localBestDist = d;
|
||||||
|
localBestT = t;
|
||||||
|
localBestPoint = p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(localBestDist < bestDist){
|
||||||
|
bestDist = localBestDist;
|
||||||
|
bestT = localBestT;
|
||||||
|
bestPoint = localBestPoint;
|
||||||
|
best = curve;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(best == null) return null;
|
||||||
|
return {
|
||||||
|
curve: best,
|
||||||
|
t: bestT,
|
||||||
|
point: {x:bestPoint.x, y:bestPoint.y, z:bestPoint.z},
|
||||||
|
distance: bestDist,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class MinHeap{
|
||||||
|
constructor(){
|
||||||
|
this.data = [];
|
||||||
|
}
|
||||||
|
push(item){
|
||||||
|
this.data.push(item);
|
||||||
|
this.bubbleUp(this.data.length - 1);
|
||||||
|
}
|
||||||
|
pop(){
|
||||||
|
if(this.data.length === 0) return null;
|
||||||
|
const root = this.data[0];
|
||||||
|
const last = this.data.pop();
|
||||||
|
if(this.data.length > 0){
|
||||||
|
this.data[0] = last;
|
||||||
|
this.bubbleDown(0);
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
bubbleUp(index){
|
||||||
|
while(index > 0){
|
||||||
|
const parent = Math.floor((index - 1) / 2);
|
||||||
|
if(this.data[parent].priority <= this.data[index].priority) break;
|
||||||
|
[this.data[parent], this.data[index]] = [this.data[index], this.data[parent]];
|
||||||
|
index = parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bubbleDown(index){
|
||||||
|
const length = this.data.length;
|
||||||
|
while(true){
|
||||||
|
let left = index * 2 + 1;
|
||||||
|
let right = left + 1;
|
||||||
|
let smallest = index;
|
||||||
|
if(left < length && this.data[left].priority < this.data[smallest].priority) smallest = left;
|
||||||
|
if(right < length && this.data[right].priority < this.data[smallest].priority) smallest = right;
|
||||||
|
if(smallest === index) break;
|
||||||
|
[this.data[smallest], this.data[index]] = [this.data[index], this.data[smallest]];
|
||||||
|
index = smallest;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
get size(){
|
||||||
|
return this.data.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function effectivePostedSpeed(edge, options){
|
||||||
|
let speed = edge.speed;
|
||||||
|
if(options.assumedUnpostedSpeed != null && speed >= options.assumedUnpostedSpeed){
|
||||||
|
speed = Math.min(speed, options.unpostedSpeedLimit);
|
||||||
|
}
|
||||||
|
if(edge.curve && edge.curve.curvature && options.tightCurveRadius != null){
|
||||||
|
const radius = 1 / edge.curve.curvature;
|
||||||
|
if(Number.isFinite(radius) && radius < options.tightCurveRadius){
|
||||||
|
speed = Math.min(speed, options.tightCurveSpeed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return speed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function effectiveSpeedUsed(edge, options){
|
||||||
|
let speed = effectivePostedSpeed(edge, options);
|
||||||
|
speed = Math.min(speed, options.maxSpeed ?? speed);
|
||||||
|
if(speed <= 0) return 0;
|
||||||
|
const gradePct = Math.abs(edge.grade) * 100;
|
||||||
|
if(options.maxGrade != null && gradePct > options.maxGrade) return 0;
|
||||||
|
const gradeFactor = Math.max(options.minGradeFactor, 1 - options.gradePenalty * gradePct);
|
||||||
|
return speed * gradeFactor;
|
||||||
|
}
|
||||||
|
|
||||||
|
function edgeCost(edge, options){
|
||||||
|
const speedKmh = effectiveSpeedUsed(edge, options);
|
||||||
|
if(speedKmh <= 0.01) return Infinity;
|
||||||
|
const speedMps = speedKmh * 1000 / 3600;
|
||||||
|
return edge.length / speedMps;
|
||||||
|
}
|
||||||
|
|
||||||
|
function segmentTravelTime(length, v0, v1, vMax, accel, decel){
|
||||||
|
if(vMax <= 0.01) return Infinity;
|
||||||
|
const a = Math.max(accel, 0.01);
|
||||||
|
const d = Math.max(decel, 0.01);
|
||||||
|
v0 = Math.min(v0, vMax);
|
||||||
|
v1 = Math.min(v1, vMax);
|
||||||
|
const accelDist = Math.max(0, (vMax*vMax - v0*v0) / (2 * a));
|
||||||
|
const decelDist = Math.max(0, (vMax*vMax - v1*v1) / (2 * d));
|
||||||
|
if(accelDist + decelDist <= length){
|
||||||
|
const cruise = length - accelDist - decelDist;
|
||||||
|
return (vMax - v0)/a + (vMax - v1)/d + cruise / vMax;
|
||||||
|
}
|
||||||
|
const numerator = 2 * length + (v0*v0)/a + (v1*v1)/d;
|
||||||
|
const denom = (1/a + 1/d);
|
||||||
|
const vPeak = Math.sqrt(Math.max(numerator / denom, 0));
|
||||||
|
return Math.max(0, (vPeak - v0)/a) + Math.max(0, (vPeak - v1)/d);
|
||||||
|
}
|
||||||
|
|
||||||
|
function estimateRouteTime(edges, speedLimits, options){
|
||||||
|
if(edges.length === 0) return 0;
|
||||||
|
const speeds = speedLimits.map(limit => Math.max(0, limit) * 1000 / 3600);
|
||||||
|
const boundaries = new Array(edges.length + 1);
|
||||||
|
boundaries[0] = 0;
|
||||||
|
boundaries[edges.length] = 0;
|
||||||
|
for(let i=1; i<edges.length; i++){
|
||||||
|
boundaries[i] = Math.min(speeds[i-1], speeds[i]);
|
||||||
|
}
|
||||||
|
let time = 0;
|
||||||
|
for(let i=0; i<edges.length; i++){
|
||||||
|
time += segmentTravelTime(
|
||||||
|
edges[i].length,
|
||||||
|
boundaries[i],
|
||||||
|
boundaries[i+1],
|
||||||
|
speeds[i],
|
||||||
|
options.accel,
|
||||||
|
options.decel
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return time;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeRouteStats(edges, options = {}){
|
||||||
|
const opts = {...defaults, ...options};
|
||||||
|
let totalLength = 0;
|
||||||
|
let maxGrade = 0;
|
||||||
|
let maxSpeedLimit = 0;
|
||||||
|
let maxSpeed = 0;
|
||||||
|
const usedLimits = [];
|
||||||
|
for(const edge of edges){
|
||||||
|
totalLength += edge.length;
|
||||||
|
maxGrade = Math.max(maxGrade, Math.abs(edge.grade) * 100);
|
||||||
|
const posted = effectivePostedSpeed(edge, opts);
|
||||||
|
const used = effectiveSpeedUsed(edge, opts);
|
||||||
|
maxSpeedLimit = Math.max(maxSpeedLimit, posted);
|
||||||
|
maxSpeed = Math.max(maxSpeed, used);
|
||||||
|
usedLimits.push(used);
|
||||||
|
}
|
||||||
|
const time = estimateRouteTime(edges, usedLimits, opts);
|
||||||
|
return {
|
||||||
|
length: totalLength,
|
||||||
|
time,
|
||||||
|
maxGrade,
|
||||||
|
maxSpeed,
|
||||||
|
maxSpeedLimit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPolyline(edges, options){
|
||||||
|
const points = [];
|
||||||
|
const stepMeters = options.polylineStepMeters;
|
||||||
|
for(const edge of edges){
|
||||||
|
const curve = edge.curve;
|
||||||
|
if(!curve) continue;
|
||||||
|
const steps = Math.max(2, Math.ceil(edge.length / stepMeters));
|
||||||
|
for(let i=0; i<=steps; i++){
|
||||||
|
const t = Utils.lerp(edge.t0, edge.t1, i/steps);
|
||||||
|
const p = Bezier.evaluatePoint(curve.start, curve.h1, curve.h2, curve.end, t);
|
||||||
|
const last = points[points.length - 1];
|
||||||
|
if(!last || last.x !== p.x || last.z !== p.z){
|
||||||
|
points.push({x:p.x, z:p.z});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findRoute(graph, startPos, endPos, options = {}){
|
||||||
|
const opts = {...defaults, ...options};
|
||||||
|
const startSnap = snapToNetwork(graph, startPos, opts);
|
||||||
|
const endSnap = snapToNetwork(graph, endPos, opts);
|
||||||
|
if(!startSnap || !endSnap) return null;
|
||||||
|
if(opts.maxSnapDistance != null){
|
||||||
|
if(startSnap.distance > opts.maxSnapDistance || endSnap.distance > opts.maxSnapDistance) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseCount = graph.nodes.length;
|
||||||
|
const startId = baseCount;
|
||||||
|
const endId = baseCount + 1;
|
||||||
|
const tempPositions = [];
|
||||||
|
tempPositions[startId] = startSnap.point;
|
||||||
|
tempPositions[endId] = endSnap.point;
|
||||||
|
|
||||||
|
const extraEdges = [];
|
||||||
|
const addTempEdge = (from, to, edge) => {
|
||||||
|
if(!extraEdges[from]) extraEdges[from] = [];
|
||||||
|
extraEdges[from].push(edge);
|
||||||
|
};
|
||||||
|
|
||||||
|
const connectSnap = (snap, nodeId) => {
|
||||||
|
const curve = snap.curve;
|
||||||
|
const startNode = curve._routeStartId;
|
||||||
|
const endNode = curve._routeEndId;
|
||||||
|
const t = snap.t;
|
||||||
|
const steps = Math.max(6, Math.ceil(curve.length / 80));
|
||||||
|
const lenToStart = approximateCurveLength(curve, 0, t, steps);
|
||||||
|
const lenToEnd = approximateCurveLength(curve, t, 1, steps);
|
||||||
|
const edgeBase = {
|
||||||
|
curve,
|
||||||
|
grade: curve.grade,
|
||||||
|
speed: curve.postedSpeed,
|
||||||
|
};
|
||||||
|
addTempEdge(nodeId, startNode, {
|
||||||
|
...edgeBase,
|
||||||
|
to: startNode,
|
||||||
|
length: lenToStart,
|
||||||
|
t0: t,
|
||||||
|
t1: 0,
|
||||||
|
});
|
||||||
|
addTempEdge(nodeId, endNode, {
|
||||||
|
...edgeBase,
|
||||||
|
to: endNode,
|
||||||
|
length: lenToEnd,
|
||||||
|
t0: t,
|
||||||
|
t1: 1,
|
||||||
|
});
|
||||||
|
addTempEdge(startNode, nodeId, {
|
||||||
|
...edgeBase,
|
||||||
|
to: nodeId,
|
||||||
|
length: lenToStart,
|
||||||
|
t0: 0,
|
||||||
|
t1: t,
|
||||||
|
});
|
||||||
|
addTempEdge(endNode, nodeId, {
|
||||||
|
...edgeBase,
|
||||||
|
to: nodeId,
|
||||||
|
length: lenToEnd,
|
||||||
|
t0: 1,
|
||||||
|
t1: t,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
connectSnap(startSnap, startId);
|
||||||
|
connectSnap(endSnap, endId);
|
||||||
|
|
||||||
|
if(startSnap.curve === endSnap.curve){
|
||||||
|
const curve = startSnap.curve;
|
||||||
|
const steps = Math.max(6, Math.ceil(curve.length / 80));
|
||||||
|
const lenBetween = approximateCurveLength(curve, startSnap.t, endSnap.t, steps);
|
||||||
|
const edgeBase = {
|
||||||
|
curve,
|
||||||
|
grade: curve.grade,
|
||||||
|
speed: curve.postedSpeed,
|
||||||
|
};
|
||||||
|
addTempEdge(startId, endId, {
|
||||||
|
...edgeBase,
|
||||||
|
to: endId,
|
||||||
|
length: lenBetween,
|
||||||
|
t0: startSnap.t,
|
||||||
|
t1: endSnap.t,
|
||||||
|
});
|
||||||
|
addTempEdge(endId, startId, {
|
||||||
|
...edgeBase,
|
||||||
|
to: startId,
|
||||||
|
length: lenBetween,
|
||||||
|
t0: endSnap.t,
|
||||||
|
t1: startSnap.t,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalNodes = baseCount + 2;
|
||||||
|
const dist = new Array(totalNodes).fill(Infinity);
|
||||||
|
const prev = new Array(totalNodes).fill(-1);
|
||||||
|
const prevEdge = new Array(totalNodes).fill(null);
|
||||||
|
const visited = new Array(totalNodes).fill(false);
|
||||||
|
const heap = new MinHeap();
|
||||||
|
|
||||||
|
const nodePosition = id => {
|
||||||
|
if(id < baseCount) return graph.nodes[id].position;
|
||||||
|
return tempPositions[id];
|
||||||
|
};
|
||||||
|
|
||||||
|
const maxSpeedMps = (opts.maxSpeed * 1000 / 3600);
|
||||||
|
const heuristic = id => {
|
||||||
|
const pos = nodePosition(id);
|
||||||
|
const target = tempPositions[endId];
|
||||||
|
return distance2D(pos, target) / Math.max(maxSpeedMps, 0.1);
|
||||||
|
};
|
||||||
|
|
||||||
|
dist[startId] = 0;
|
||||||
|
heap.push({id:startId, priority:heuristic(startId)});
|
||||||
|
|
||||||
|
const getNeighbors = id => {
|
||||||
|
const baseEdges = id < baseCount ? graph.edges[id] : [];
|
||||||
|
const extra = extraEdges[id] ?? [];
|
||||||
|
return baseEdges.concat(extra);
|
||||||
|
};
|
||||||
|
|
||||||
|
while(heap.size){
|
||||||
|
const current = heap.pop();
|
||||||
|
if(!current) break;
|
||||||
|
const id = current.id;
|
||||||
|
if(visited[id]) continue;
|
||||||
|
visited[id] = true;
|
||||||
|
if(id === endId) break;
|
||||||
|
for(const edge of getNeighbors(id)){
|
||||||
|
const cost = edgeCost(edge, opts);
|
||||||
|
if(cost === Infinity) continue;
|
||||||
|
const next = edge.to;
|
||||||
|
const alt = dist[id] + cost;
|
||||||
|
if(alt < dist[next]){
|
||||||
|
dist[next] = alt;
|
||||||
|
prev[next] = id;
|
||||||
|
prevEdge[next] = edge;
|
||||||
|
heap.push({id:next, priority:alt + heuristic(next)});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!Number.isFinite(dist[endId])) return null;
|
||||||
|
|
||||||
|
const pathEdges = [];
|
||||||
|
let cur = endId;
|
||||||
|
while(cur !== startId && cur !== -1){
|
||||||
|
const edge = prevEdge[cur];
|
||||||
|
if(!edge) break;
|
||||||
|
pathEdges.push(edge);
|
||||||
|
cur = prev[cur];
|
||||||
|
}
|
||||||
|
pathEdges.reverse();
|
||||||
|
|
||||||
|
return {
|
||||||
|
edges: pathEdges,
|
||||||
|
polyline: buildPolyline(pathEdges, opts),
|
||||||
|
stats: computeRouteStats(pathEdges, opts),
|
||||||
|
snaps: {
|
||||||
|
start: startSnap,
|
||||||
|
end: endSnap,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import * as Config from './config.js';
|
|||||||
import { Color } from './js/colorlib.js';
|
import { Color } from './js/colorlib.js';
|
||||||
import * as MapData from './js/mapdata.js';
|
import * as MapData from './js/mapdata.js';
|
||||||
|
|
||||||
const settingsVersion = 1;
|
const settingsVersion = 2;
|
||||||
|
|
||||||
let settingEntries = [
|
let settingEntries = [
|
||||||
{
|
{
|
||||||
@@ -17,6 +17,72 @@ let settingEntries = [
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Catenary Network',
|
||||||
|
id: 'toggle_catenary',
|
||||||
|
saveState: true,
|
||||||
|
state: true,
|
||||||
|
func: state =>{
|
||||||
|
MapData.layers.catenary = state;
|
||||||
|
MapData.view.dirty = true;
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
label: 'Catenary Color Mode',
|
||||||
|
id: 'dropdown_catenaryColor',
|
||||||
|
saveState: true,
|
||||||
|
options: [
|
||||||
|
'Voltage',
|
||||||
|
'Substation',
|
||||||
|
'Wire Type',
|
||||||
|
],
|
||||||
|
state: 'Voltage',
|
||||||
|
func: state =>{
|
||||||
|
MapData.setCatenaryColorMode(state);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Contact Wires',
|
||||||
|
id: 'toggle_catenaryWires',
|
||||||
|
saveState: true,
|
||||||
|
state: true,
|
||||||
|
func: state =>{
|
||||||
|
MapData.layers.catenaryWires = state;
|
||||||
|
MapData.view.dirty = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Traction Substations',
|
||||||
|
id: 'toggle_catenarySubstations',
|
||||||
|
saveState: true,
|
||||||
|
state: true,
|
||||||
|
func: state =>{
|
||||||
|
MapData.layers.catenarySubstations = state;
|
||||||
|
MapData.view.dirty = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Neutral Sections (Токоразделы)',
|
||||||
|
id: 'toggle_catenaryNeutrals',
|
||||||
|
saveState: true,
|
||||||
|
state: true,
|
||||||
|
func: state =>{
|
||||||
|
MapData.layers.catenaryNeutrals = state;
|
||||||
|
MapData.view.dirty = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Interruption Signs',
|
||||||
|
id: 'toggle_catenaryInterruptions',
|
||||||
|
saveState: true,
|
||||||
|
state: true,
|
||||||
|
func: state =>{
|
||||||
|
MapData.layers.catenaryInterruptions = state;
|
||||||
|
MapData.view.dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Color Mode',
|
label: 'Color Mode',
|
||||||
id: 'dropdown_trackColor',
|
id: 'dropdown_trackColor',
|
||||||
@@ -256,7 +322,20 @@ let settingEntries = [
|
|||||||
func: state =>{
|
func: state =>{
|
||||||
MapData.layers.player = state;
|
MapData.layers.player = state;
|
||||||
MapData.view.dynDirty = true;
|
MapData.view.dynDirty = true;
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
label: 'Follow Player',
|
||||||
|
tooltip: 'Keep View centered on player',
|
||||||
|
id: 'toggle_gps',
|
||||||
|
saveState: true,
|
||||||
|
state: true,
|
||||||
|
func: state =>{
|
||||||
|
MapData.layers.gps = state;
|
||||||
|
MapData.view.dynDirty = true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Train Cars',
|
label: 'Train Cars',
|
||||||
@@ -303,18 +382,10 @@ export function initialize(){
|
|||||||
const legend = document.getElementById('legend');
|
const legend = document.getElementById('legend');
|
||||||
const legendContents = document.getElementById('legendContents');
|
const legendContents = document.getElementById('legendContents');
|
||||||
const legendButton = document.getElementById('legendButton');
|
const legendButton = document.getElementById('legendButton');
|
||||||
const legendArrow = legendButton.children[0];
|
|
||||||
let legendState = true;
|
|
||||||
let legendButtonEvent = e => {
|
|
||||||
legendArrow.style.transform = `rotate(${legendState ? 180 : 0}deg) translateY(${legendState ? -0 : 0}px)`;
|
|
||||||
legend.style.transform = `translateX(${legendState ? 0 : -legendContents.clientWidth}px)`;
|
|
||||||
legendState = !legendState;
|
|
||||||
};
|
|
||||||
legendButton.addEventListener('click', legendButtonEvent);
|
|
||||||
legendButtonEvent();
|
|
||||||
if(document.body.clientWidth <= 700) legendButtonEvent();
|
|
||||||
populateKey();
|
populateKey();
|
||||||
setLastUpdateData();
|
setLastUpdateData();
|
||||||
|
initTabs();
|
||||||
|
document.getElementById('sidepanel').style.visibility = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
function addSettingEntry(thisSetting, parent, indent=0){
|
function addSettingEntry(thisSetting, parent, indent=0){
|
||||||
@@ -358,6 +429,7 @@ function addSettingEntry(thisSetting, parent, indent=0){
|
|||||||
if(inputEntry == thisSetting.state) optionElement.selected = true;
|
if(inputEntry == thisSetting.state) optionElement.selected = true;
|
||||||
thisSetting.inputElement.appendChild(optionElement);
|
thisSetting.inputElement.appendChild(optionElement);
|
||||||
}
|
}
|
||||||
|
if(thisSetting.state) thisSetting.inputElement.value = thisSetting.state;
|
||||||
if(thisSetting.func){
|
if(thisSetting.func){
|
||||||
thisSetting.inputElement.addEventListener('change', e =>{
|
thisSetting.inputElement.addEventListener('change', e =>{
|
||||||
thisSetting.func(e.target.value);
|
thisSetting.func(e.target.value);
|
||||||
@@ -372,6 +444,7 @@ function addSettingEntry(thisSetting, parent, indent=0){
|
|||||||
thisSetting.inputElement.min = thisSetting.min ?? 0;
|
thisSetting.inputElement.min = thisSetting.min ?? 0;
|
||||||
thisSetting.inputElement.max = thisSetting.max ?? 100;
|
thisSetting.inputElement.max = thisSetting.max ?? 100;
|
||||||
thisSetting.inputElement.step = thisSetting.step ?? 1;
|
thisSetting.inputElement.step = thisSetting.step ?? 1;
|
||||||
|
thisSetting.inputElement.value = thisSetting.state ?? 0;
|
||||||
if(thisSetting.func){
|
if(thisSetting.func){
|
||||||
thisSetting.inputElement.addEventListener('input', e => {
|
thisSetting.inputElement.addEventListener('input', e => {
|
||||||
thisSetting.func(e.target.value);
|
thisSetting.func(e.target.value);
|
||||||
@@ -536,6 +609,50 @@ async function setLastUpdateData(){
|
|||||||
setTimeout(() => {setLastUpdateData()}, 600000);
|
setTimeout(() => {setLastUpdateData()}, 600000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function initTabs(){
|
||||||
|
const tabContents = document.getElementById('tabContents');
|
||||||
|
let tabs = [];
|
||||||
|
let totalOffset = 5;
|
||||||
|
let maxButtonWidth = 0;
|
||||||
|
const updateTabPosition = tabData => {
|
||||||
|
//tabData.element.style.zIndex = tabData.visible ? 10 : '';
|
||||||
|
tabData.element.style.transform = `translateX(${tabData.visible ? maxButtonWidth : -(tabData.content.clientWidth + 10)}px)`;
|
||||||
|
};
|
||||||
|
for(let tabButton of document.querySelectorAll('#tabContents .tabButton')){
|
||||||
|
maxButtonWidth = Math.max(maxButtonWidth, tabButton.clientWidth);
|
||||||
|
}
|
||||||
|
for(let tabElement of tabContents.children){
|
||||||
|
let newTab = {
|
||||||
|
element: tabElement,
|
||||||
|
button: tabElement.getElementsByClassName('tabButton')[0],
|
||||||
|
content: tabElement.getElementsByClassName('tabContent')[0],
|
||||||
|
visible: tabElement.dataset.tab,
|
||||||
|
};
|
||||||
|
newTab.button.style.transform = `translateY(${totalOffset}px)`;
|
||||||
|
totalOffset += newTab.button.clientHeight + 6;
|
||||||
|
updateTabPosition(newTab);
|
||||||
|
tabs.push(newTab);
|
||||||
|
|
||||||
|
newTab.button.addEventListener('click', () => {
|
||||||
|
for(let tab of tabs){
|
||||||
|
if(tab == newTab)
|
||||||
|
tab.visible = !tab.visible;
|
||||||
|
else
|
||||||
|
tab.visible = false;
|
||||||
|
|
||||||
|
if(tab.visible)
|
||||||
|
tab.element.dataset.tab = 'open';
|
||||||
|
else
|
||||||
|
delete tab.element.dataset.tab;
|
||||||
|
|
||||||
|
updateTabPosition(tab);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await new Promise(requestAnimationFrame);
|
||||||
|
tabContents.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
function populateKey(){
|
function populateKey(){
|
||||||
addKeyEntry('gradeArrow', 'Grade Indication')
|
addKeyEntry('gradeArrow', 'Grade Indication')
|
||||||
addKeyEntry('speed_5', 'Speed Limit');
|
addKeyEntry('speed_5', 'Speed Limit');
|
||||||
@@ -551,10 +668,18 @@ function populateKey(){
|
|||||||
addKeyEntry('garage', 'Garage');
|
addKeyEntry('garage', 'Garage');
|
||||||
addKeyEntry('demonstrator', 'Demonstrator Spawn');
|
addKeyEntry('demonstrator', 'Demonstrator Spawn');
|
||||||
addKeyEntry('player', 'Player');
|
addKeyEntry('player', 'Player');
|
||||||
//Add empty entries to make entries align right in the columns.
|
populateCatenaryKey();
|
||||||
/*for(let i=0; i<1; i++){
|
}
|
||||||
addKeyEntry();
|
|
||||||
}*/
|
function populateCatenaryKey(){
|
||||||
|
const catKey = document.getElementById('legendCatenaryKey');
|
||||||
|
if(!catKey) return;
|
||||||
|
catKey.innerHTML = `
|
||||||
|
<div style="margin: 3px 0;"><span style="display:inline-block;width:1.2em;height:1.2em;background:#0077b6;border-radius:50%;text-align:center;line-height:1.2em;font-size:11px;color:#fff;border:1px solid #fff;">⚡</span> Traction Substation</div>
|
||||||
|
<div style="margin: 3px 0;"><span style="display:inline-block;width:1.2em;height:1.2em;background:#e63946;border-radius:3px;text-align:center;line-height:1.2em;font-size:10px;color:#fff;border:1px dashed #fff;">⛔</span> Neutral Section (0V Токораздел)</div>
|
||||||
|
<div style="margin: 3px 0;"><span style="display:inline-block;width:1.2em;height:1.2em;background:#ff9f1c;border-radius:3px;text-align:center;line-height:1.2em;font-size:11px;color:#000;border:1px solid #000;">⤓</span> Lower Pantographs Warning</div>
|
||||||
|
`;
|
||||||
|
MapData.updateCatenaryLegend();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
:root{
|
:root{
|
||||||
--absScale: 1;
|
--absScale: 1;
|
||||||
--legendButtonSize: 50px;
|
--legendButtonSize: 50px;
|
||||||
|
--accentCol: #e79830;
|
||||||
|
--bg1: #2c3f58;
|
||||||
|
--bg2: #191e2e;
|
||||||
}
|
}
|
||||||
|
|
||||||
html *{
|
html *{
|
||||||
@@ -28,11 +31,11 @@ a:visited{
|
|||||||
color: #5ea8e4;
|
color: #5ea8e4;
|
||||||
}
|
}
|
||||||
a:hover{
|
a:hover{
|
||||||
color: #e79830;
|
color: var(--accentCol);
|
||||||
}
|
}
|
||||||
|
|
||||||
.orange{
|
.orange, .tabButton{
|
||||||
color: #e79830;
|
color: var(--accentCol);
|
||||||
}
|
}
|
||||||
|
|
||||||
.quiet{
|
.quiet{
|
||||||
@@ -44,9 +47,9 @@ a:hover{
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel{
|
.panel, .tabContent, .tabButton{
|
||||||
border: 2px solid #b3b3b396;
|
border: 2px solid #b3b3b396;
|
||||||
background: linear-gradient(120deg, #2c3f58, #191e2e);
|
background: linear-gradient(120deg, var(--bg1), var(--bg2));
|
||||||
text-shadow: 2px 2px 5px #0003;
|
text-shadow: 2px 2px 5px #0003;
|
||||||
margin: 5px;
|
margin: 5px;
|
||||||
padding: 8px 13px;
|
padding: 8px 13px;
|
||||||
@@ -54,7 +57,7 @@ a:hover{
|
|||||||
pointer-events: all;
|
pointer-events: all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel hr {
|
.panel hr, .tabContent hr {
|
||||||
border: 0px;
|
border: 0px;
|
||||||
border-bottom: 2px solid #b3b3b396;
|
border-bottom: 2px solid #b3b3b396;
|
||||||
}
|
}
|
||||||
@@ -77,7 +80,7 @@ a:hover{
|
|||||||
}
|
}
|
||||||
a:hover{
|
a:hover{
|
||||||
color: #31281b;
|
color: #31281b;
|
||||||
background: #e79830;
|
background: var(--accentCol);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,11 +91,42 @@ a:hover{
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
max-width: min(500px,100vw);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#tabContents{
|
||||||
|
margin-left: 5px;
|
||||||
|
>div{
|
||||||
|
position: fixed;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
>div{
|
||||||
|
max-width: min(500px,100vw);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#tabContents.active>div{
|
||||||
|
transition: transform 400ms cubic-bezier(0.445, 0.050, 0.550, 0.950);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabButton{
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
border-left: none;
|
||||||
|
font-size: 25px;
|
||||||
|
padding: 8px;
|
||||||
|
background: var(--bg1);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.1s cubic-bezier(0.250, 0.460, 0.450, 0.940);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabButton:hover{
|
||||||
|
background: var(--accentCol);
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
#headerbar{
|
#headerbar{
|
||||||
background: linear-gradient(120deg, #3d5779, #121522);
|
background: linear-gradient(120deg, #3d5779, #121522);
|
||||||
font-size: 2em;
|
font-size: 2em;
|
||||||
@@ -136,6 +170,135 @@ a:hover{
|
|||||||
pointer-events: all;
|
pointer-events: all;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#directionsPanel{
|
||||||
|
max-width: min(360px, 92vw);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
transition: transform 400ms cubic-bezier(0.445, 0.050, 0.550, 0.950);
|
||||||
|
}
|
||||||
|
|
||||||
|
#directionsContents{
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
#directionsHeader{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-variation-settings: "wght" 600;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#directionsHeaderRight{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#directionsBody{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-width: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directionToggle{
|
||||||
|
padding: 8px;
|
||||||
|
height: var(--legendButtonSize);
|
||||||
|
width: var(--legendButtonSize);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 1;
|
||||||
|
margin-left: 0px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#directionsStops{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#directionsControls{
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#directionsOptions{
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#directionsOptions label{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#directionsOptions input{
|
||||||
|
width: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#directionsSummary{
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directionRow{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directionBadge{
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 11px;
|
||||||
|
font-variation-settings: "wght" 700;
|
||||||
|
color: #0b0f1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directionInput{
|
||||||
|
flex: 1;
|
||||||
|
background: #111827;
|
||||||
|
color: #e9e9e9;
|
||||||
|
border: 1px solid #374151;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 3px 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directionInput::placeholder{
|
||||||
|
color: #9aa0a6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directionButton{
|
||||||
|
border: 1px solid #374151;
|
||||||
|
background: #1f2937;
|
||||||
|
color: #e9e9e9;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directionButton.active{
|
||||||
|
border-color: var(--accentCol);
|
||||||
|
color: #31281b;
|
||||||
|
background: var(--accentCol);
|
||||||
|
}
|
||||||
|
|
||||||
|
.directionButton:disabled{
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
#redrawTrigger{
|
#redrawTrigger{
|
||||||
transition: background-color 800ms;
|
transition: background-color 800ms;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|||||||
Reference in new issue
Block a user