/*
* TODO:
*
* - Culling
* - Threat zones
* - Navmeshes
* - Time of day/weather themeing
* - Exclusion zones
* - Supply route status
* - "Actual" front line
* - Debug flight plan drawing
* - Icon variety
*/
const Colors = Object.freeze({
Blue: "#0084ff",
Red: "#c85050",
Green: "#80BA80",
Highlight: "#ffff00",
});
function metersToNauticalMiles(meters) {
return meters * 0.000539957;
}
function formatLatLng(latLng) {
const lat = latLng.lat.toFixed(2);
const lng = latLng.lng.toFixed(2);
const ns = lat >= 0 ? "N" : "S";
const ew = lng >= 0 ? "E" : "W";
return `${lat}°${ns} ${lng}°${ew}`;
}
const map = L.map("map").setView([0, 0], 3);
L.control.scale({ maxWidth: 200 }).addTo(map);
// https://esri.github.io/esri-leaflet/api-reference/layers/basemap-layer.html
const baseLayers = {
"Imagery Clarity": L.esri.basemapLayer("ImageryClarity", { maxZoom: 17 }),
"Imagery Firefly": L.esri.basemapLayer("ImageryFirefly", { maxZoom: 17 }),
Topographic: L.esri.basemapLayer("Topographic", { maxZoom: 16 }),
};
const defaultBaseMap = baseLayers["Imagery Clarity"];
defaultBaseMap.addTo(map);
// Enabled by default, so addTo(map).
const controlPointsLayer = L.layerGroup().addTo(map);
const groundObjectsLayer = L.markerClusterGroup({ maxClusterRadius: 40 }).addTo(
map
);
const supplyRoutesLayer = L.layerGroup().addTo(map);
const frontLinesLayer = L.layerGroup().addTo(map);
const redSamThreatLayer = L.layerGroup().addTo(map);
const blueFlightPlansLayer = L.layerGroup().addTo(map);
// Added to map by the user via layer controls.
const blueSamThreatLayer = L.layerGroup();
const blueSamDetectionLayer = L.layerGroup();
const redSamDetectionLayer = L.layerGroup();
const redFlightPlansLayer = L.layerGroup();
const selectedFlightPlansLayer = L.layerGroup();
L.control
.groupedLayers(
baseLayers,
{
"Points of Interest": {
"Control points": controlPointsLayer,
"Ground objects": groundObjectsLayer,
"Supply routes": supplyRoutesLayer,
"Front lines": frontLinesLayer,
},
"Air Defenses": {
"Ally SAM threat range": blueSamThreatLayer,
"Enemy SAM threat range": redSamThreatLayer,
"Ally SAM detection range": blueSamDetectionLayer,
"Enemy SAM detection range": redSamDetectionLayer,
},
"Flight Plans": {
Hide: L.layerGroup(),
"Show selected blue": selectedFlightPlansLayer,
"Show all blue": blueFlightPlansLayer,
"Show all red": redFlightPlansLayer,
},
},
{ collapsed: false, exclusiveGroups: ["Flight Plans"] }
)
.addTo(map);
const friendlyCpIcon = new L.Icon({
iconUrl:
"https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-blue.png",
shadowUrl:
"https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png",
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41],
});
const enemyCpIcon = new L.Icon({
iconUrl:
"https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-red.png",
shadowUrl:
"https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png",
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41],
});
let game;
new QWebChannel(qt.webChannelTransport, function (channel) {
game = channel.objects.game;
drawInitialMap();
game.cleared.connect(clearAllLayers);
game.mapCenterChanged.connect(recenterMap);
game.controlPointsChanged.connect(drawControlPoints);
game.groundObjectsChanged.connect(drawGroundObjects);
game.supplyRoutesChanged.connect(drawSupplyRoutes);
game.frontLinesChanged.connect(drawFrontLines);
game.flightsChanged.connect(drawFlightPlans);
});
function recenterMap(center) {
map.setView(center, 8, { animate: true, duration: 1 });
}
function iconFor(player) {
if (player) {
return friendlyCpIcon;
} else {
return enemyCpIcon;
}
}
const SHOW_BASE_NAME_AT_ZOOM = 8;
class ControlPoint {
constructor(cp) {
this.cp = cp;
// The behavior we want is for the CP to be draggable when it has no
// destination, but for the destination to be draggable when it does. The
// primary marker is always shown and draggable. When a destination exists,
// the primary marker marks the destination and the secondary marker marks
// the location. When no destination exists, the primary marker marks the
// location and the secondary marker is not shown.
this.primaryMarker = this.makePrimaryMarker();
this.secondaryMarker = this.makeSecondaryMarker();
this.path = this.makePath();
this.attachTooltipsAndHandlers();
this.cp.destinationChanged.connect(() => this.onDestinationChanged());
}
hasDestination() {
return this.cp.destination.length > 0;
}
hideDestination() {
this.secondaryMarker.removeFrom(controlPointsLayer);
this.path.removeFrom(controlPointsLayer);
}
setDestination(destination) {
this.cp.setDestination([destination.lat, destination.lng]).then((err) => {
if (err) {
console.log(`Could not set control point destination: ${err}`);
this.locationMarker().bindPopup(err).openPopup();
// Reset markers and paths on error. On success this happens when we get
// the destinationChanged signal from the backend.
this.onDestinationChanged();
}
});
}
onDrag(destination) {
this.path.setLatLngs([this.cp.position, destination]);
this.path.addTo(controlPointsLayer);
const distance = metersToNauticalMiles(
destination.distanceTo(this.cp.position)
);
this.primaryMarker.unbindTooltip();
this.primaryMarker.bindTooltip(
`Move ${distance.toFixed(1)}nm to ${formatLatLng(destination)}`,
{
permanent: true,
}
);
this.cp
.destinationInRange([destination.lat, destination.lng])
.then((inRange) => {
this.path.setStyle({
color: inRange ? Colors.Green : Colors.Red,
});
});
}
detachTooltipsAndHandlers() {
this.primaryMarker.unbindTooltip();
this.primaryMarker.off("click");
this.primaryMarker.off("contextmenu");
this.secondaryMarker.unbindTooltip();
this.secondaryMarker.off("click");
this.secondaryMarker.off("contextmenu");
}
locationMarker(dragging = false) {
return this.hasDestination() || dragging
? this.secondaryMarker
: this.primaryMarker;
}
destinationMarker() {
return this.hasDestination() ? this.primaryMarker : null;
}
attachTooltipsAndHandlers(dragging = false) {
this.detachTooltipsAndHandlers();
const zoom = map.getZoom();
const locationMarker = this.locationMarker(dragging);
const destinationMarker = this.destinationMarker();
locationMarker
.bindTooltip(`
${this.cp.name}
`, {
permanent: zoom >= SHOW_BASE_NAME_AT_ZOOM,
})
.on("click", () => {
this.cp.showInfoDialog();
})
.on("contextmenu", () => {
this.cp.showPackageDialog();
});
if (destinationMarker != null) {
const origin = locationMarker.getLatLng();
const destination = destinationMarker.getLatLng();
const distance = metersToNauticalMiles(
destination.distanceTo(origin)
).toFixed(1);
const dest = formatLatLng(destination);
destinationMarker.bindTooltip(
`${this.cp.name} moving ${distance}nm to ${dest} next turn`
);
destinationMarker.on("contextmenu", () => this.cp.cancelTravel());
destinationMarker.addTo(map);
}
}
makePrimaryMarker() {
const location = this.hasDestination()
? this.cp.destination
: this.cp.position;
// We might draw other markers on top of the CP. The tooltips from the other
// markers are helpful so we want to keep them, but make sure the CP is
// always the clickable thing.
return L.marker(location, {
icon: iconFor(this.cp.blue),
zIndexOffset: 1000,
draggable: this.cp.mobile,
autoPan: true,
})
.on("dragstart", () => {
this.secondaryMarker.addTo(controlPointsLayer);
this.attachTooltipsAndHandlers(true);
})
.on("drag", (event) => {
const marker = event.target;
const newPosition = marker.getLatLng();
this.onDrag(newPosition);
})
.on("dragend", (event) => {
const marker = event.target;
const newPosition = marker.getLatLng();
this.setDestination(newPosition);
})
.addTo(map);
}
makeSecondaryMarker() {
return L.marker(this.cp.position, {
icon: iconFor(this.cp.blue),
zIndexOffset: 1000,
});
}
makePath() {
const destination = this.hasDestination() ? this.cp.destination : [0, 0];
return L.polyline([this.cp.position, destination], {
color: Colors.Green,
weight: 1,
});
}
onDestinationChanged() {
if (this.hasDestination()) {
this.primaryMarker.setLatLng(this.cp.destination);
this.secondaryMarker.addTo(controlPointsLayer);
this.path.setLatLngs([this.cp.position, this.cp.destination]);
this.path.addTo(controlPointsLayer);
this.path.setStyle({ color: Colors.Green });
} else {
this.hideDestination();
this.primaryMarker.setLatLng(this.cp.position);
}
this.attachTooltipsAndHandlers();
}
drawDestination() {
this.secondaryMarker.addTo(controlPointsLayer);
this.path.addTo(controlPointsLayer);
}
draw() {
this.primaryMarker.addTo(controlPointsLayer);
if (this.hasDestination()) {
this.drawDestination();
}
}
}
function drawControlPoints() {
controlPointsLayer.clearLayers();
game.controlPoints.forEach((cp) => {
new ControlPoint(cp).draw();
});
}
function drawSamThreatsAt(tgo) {
const detectionLayer = tgo.blue
? blueSamDetectionLayer
: redSamDetectionLayer;
const threatLayer = tgo.blue ? blueSamThreatLayer : redSamThreatLayer;
const threatColor = tgo.blue ? Colors.Blue : Colors.Red;
const detectionColor = tgo.blue ? "#bb89ff" : "#eee17b";
tgo.samDetectionRanges.forEach((range) => {
L.circle(tgo.position, {
radius: range,
color: detectionColor,
fill: false,
weight: 1,
}).addTo(detectionLayer);
});
tgo.samThreatRanges.forEach((range) => {
L.circle(tgo.position, {
radius: range,
color: threatColor,
fill: false,
weight: 2,
}).addTo(threatLayer);
});
}
function drawGroundObjects() {
groundObjectsLayer.clearLayers();
blueSamDetectionLayer.clearLayers();
redSamDetectionLayer.clearLayers();
blueSamThreatLayer.clearLayers();
redSamThreatLayer.clearLayers();
game.groundObjects.forEach((tgo) => {
L.marker(tgo.position, { icon: iconFor(tgo.blue) })
.bindTooltip(`${tgo.name}
${tgo.units.join("
")}`)
.on("click", function () {
tgo.showInfoDialog();
})
.on("contextmenu", function () {
tgo.showPackageDialog();
})
.addTo(groundObjectsLayer);
drawSamThreatsAt(tgo);
});
}
function drawSupplyRoutes() {
supplyRoutesLayer.clearLayers();
game.supplyRoutes.forEach((route) => {
let color;
if (route.frontActive) {
color = Colors.Red;
} else if (route.blue) {
color = "#2d3e50";
} else {
color = "#8c1414";
}
L.polyline(route.points, {
color: color,
weight: route.isSea ? 4 : 6,
}).addTo(supplyRoutesLayer);
});
}
function drawFrontLines() {
frontLinesLayer.clearLayers();
game.frontLines.forEach((front) => {
L.polyline(front.extents, { weight: 8, color: "#fe7d0a" })
.on("contextmenu", function () {
front.showPackageDialog();
})
.addTo(frontLinesLayer);
});
}
const SHOW_WAYPOINT_INFO_AT_ZOOM = 9;
class Waypoint {
constructor(waypoint, flight) {
this.waypoint = waypoint;
this.flight = flight;
this.marker = this.makeMarker();
this.waypoint.positionChanged.connect(() => this.relocate());
this.waypoint.timingChanged.connect(() => this.updateDescription());
}
position() {
return this.waypoint.position;
}
shouldMark() {
// We don't need a marker for the departure waypoint (and it's likely
// coincident with the landing waypoint, so hard to see). We do want to draw
// the path from it though.
return !this.waypoint.isTakeoff;
}
description(dragging) {
const timing = dragging
? "Waiting to recompute TOT..."
: this.waypoint.timing;
return (
`${this.waypoint.number} ${this.waypoint.name}
` +
`${this.waypoint.altitudeFt} ft ${this.waypoint.altitudeReference}
` +
`${timing}`
);
}
relocate() {
this.marker.setLatLng(this.waypoint.position);
}
updateDescription(dragging) {
this.marker.setTooltipContent(this.description(dragging));
}
makeMarker() {
const zoom = map.getZoom();
return L.marker(this.waypoint.position, { draggable: true })
.bindTooltip(this.description(), {
permanent: zoom >= SHOW_WAYPOINT_INFO_AT_ZOOM,
})
.on("dragstart", (e) => {
this.updateDescription(true);
})
.on("drag", (e) => {
const marker = e.target;
const destination = marker.getLatLng();
this.flight.updatePath(this.waypoint.number, destination);
})
.on("dragend", (e) => {
const marker = e.target;
const destination = marker.getLatLng();
this.waypoint
.setPosition([destination.lat, destination.lng])
.then((err) => {
if (err) {
console.log(err);
marker.bindPopup(err);
}
});
});
}
includeInPath() {
return !this.waypoint.isDivert;
}
}
class Flight {
constructor(flight) {
this.flight = flight;
this.flightPlan = this.flight.flightPlan.map((p) => new Waypoint(p, this));
this.path = null;
this.flight.flightPlanChanged.connect(() => this.draw());
}
shouldMark(waypoint) {
return this.flight.selected && waypoint.shouldMark();
}
flightPlanLayer() {
return this.flight.blue ? blueFlightPlansLayer : redFlightPlansLayer;
}
updatePath(idx, position) {
const points = this.path.getLatLngs();
points[idx] = position;
this.path.setLatLngs(points);
}
drawPath(path) {
const color = this.flight.blue ? Colors.Blue : Colors.Red;
const layer = this.flightPlanLayer();
if (this.flight.selected) {
this.path = L.polyline(path, { color: Colors.Highlight })
.addTo(layer)
.addTo(selectedFlightPlansLayer);
} else {
this.path = L.polyline(path, { color: color }).addTo(layer);
}
}
drawCommitBoundary() {
if (this.flight.selected) {
if (this.flight.commitBoundary) {
L.polyline(this.flight.commitBoundary, {
color: Colors.Highlight,
weight: 1,
})
.addTo(this.flightPlanLayer())
.addTo(selectedFlightPlansLayer);
}
}
}
draw() {
const path = [];
this.flightPlan.forEach((waypoint) => {
if (waypoint.includeInPath()) {
path.push(waypoint.position());
}
if (this.shouldMark(waypoint)) {
waypoint.marker
.addTo(this.flightPlanLayer())
.addTo(selectedFlightPlansLayer);
}
});
this.drawPath(path);
this.drawCommitBoundary();
}
}
function drawFlightPlans() {
blueFlightPlansLayer.clearLayers();
redFlightPlansLayer.clearLayers();
selectedFlightPlansLayer.clearLayers();
let selected = null;
game.flights.forEach((flight) => {
// Draw the selected waypoint last so it's on top. bringToFront only brings
// it to the front of the *extant* elements, so any flights drawn later will
// be drawn on top. We could fight with manual Z-indexes but leaflet does a
// lot of that automatically so it'd be error prone.
if (flight.selected) {
selected = flight;
} else {
new Flight(flight).draw();
}
});
if (selected != null) {
new Flight(selected).draw();
}
}
function drawInitialMap() {
recenterMap(game.mapCenter);
drawControlPoints();
drawGroundObjects();
drawSupplyRoutes();
drawFrontLines();
drawFlightPlans();
}
function clearAllLayers() {
map.eachLayer(function (layer) {
if (layer.clearLayers !== undefined) {
layer.clearLayers();
}
});
}
function setTooltipZoomThreshold(layerGroup, showAt) {
let showing = map.getZoom() >= showAt;
map.on("zoomend", function () {
const zoom = map.getZoom();
if (zoom < showAt && showing) {
showing = false;
layerGroup.eachLayer(function (layer) {
if (layer.getTooltip()) {
const tooltip = layer.getTooltip();
layer.unbindTooltip().bindTooltip(tooltip, {
permanent: false,
});
}
});
} else if (zoom >= showAt && !showing) {
showing = true;
layerGroup.eachLayer(function (layer) {
if (layer.getTooltip()) {
const tooltip = layer.getTooltip();
layer.unbindTooltip().bindTooltip(tooltip, {
permanent: true,
});
}
});
}
});
}
setTooltipZoomThreshold(controlPointsLayer, SHOW_BASE_NAME_AT_ZOOM);
setTooltipZoomThreshold(selectedFlightPlansLayer, SHOW_WAYPOINT_INFO_AT_ZOOM);