const ENABLE_EXPENSIVE_DEBUG_TOOLS = false;
// Must be kept in sync with game.server.settings.ServerSettings.
const HTTP_BACKEND = "http://[::1]:5000";
const WS_BACKEND = "ws://[::1]:5000/eventstream";
METERS_TO_FEET = 3.28084;
// Uniquely generated at startup and passed to use by the QWebChannel.
var API_KEY = null;
function getJson(endpoint) {
return fetch(`${HTTP_BACKEND}${endpoint}`, {
headers: {
"X-API-Key": API_KEY,
},
}).then((response) => response.json());
}
function postJson(endpoint, data) {
return fetch(`${HTTP_BACKEND}${endpoint}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": API_KEY,
},
body: JSON.stringify(data),
}).then((response) => response.json());
}
const Colors = Object.freeze({
Blue: "#0084ff",
Red: "#c85050",
Green: "#80BA80",
Highlight: "#ffff00",
});
const Categories = Object.freeze([
"aa",
"allycamp",
"ammo",
"armor",
"coastal",
"comms",
"derrick",
"ewr",
"factory",
"farp",
"fuel",
"missile",
"oil",
"power",
"ship",
"village",
"ware",
"ww2bunker",
]);
const UnitState = Object.freeze({
Alive: "alive",
Damaged: "damaged",
Destroyed: "destroyed",
});
class CpIcons {
constructor() {
this.icons = {};
for (const player of [true, false]) {
this.icons[player] = {};
for (const state of Object.values(UnitState)) {
this.icons[player][state] = {
airfield: this.loadIcon("airfield", player, state),
cv: this.loadIcon("cv", player, state),
fob: this.loadIcon("fob", player, state),
lha: this.loadIcon("lha", player, state),
offmap: this.loadIcon("airfield", player, state),
};
}
}
}
icon(category, player, state) {
return this.icons[player][state][category];
}
loadIcon(category, player, state) {
const color = player ? "blue" : "red";
return new L.Icon({
iconUrl: `../ground_assets/${category}_${color}_${state}.svg`,
iconSize: [32, 32],
});
}
}
class TgoIcons {
constructor() {
this.icons = {};
for (const category of Categories) {
this.icons[category] = {};
for (const player of [true, false]) {
this.icons[category][player] = {};
for (const state of Object.values(UnitState)) {
this.icons[category][player][state] = this.loadIcon(
category,
player,
state
);
}
}
}
}
icon(category, player, state) {
return this.icons[category][player][state];
}
loadIcon(category, player, state) {
const color = player ? "blue" : "red";
return new L.Icon({
iconUrl: `../ground_assets/${category}_${color}_${state}.svg`,
iconSize: [32, 32],
});
}
loadLegacyIcon(category, player) {
const playerSuffix = player ? "_blue" : "";
return new L.Icon({
iconUrl: `../ground_assets/${category}${playerSuffix}.png`,
});
}
}
class AirIcons {
constructor() {
this.icons = {};
for (const player of [true, false]) {
this.icons[player] = {};
for (const selected of [true, false]) {
this.icons[player][selected] = this.loadIcon(
"unspecified",
player,
selected
);
}
}
}
icon(_category, player, selected) {
return this.icons[player][selected];
}
loadIcon(category, player, selected) {
var color;
if (selected) {
color = "selected";
} else {
color = player ? "blue" : "red";
}
return new L.Icon({
iconUrl: `../air_assets/${category}_${color}.svg`,
iconSize: [24, 24],
});
}
}
const Icons = Object.freeze({
ControlPoints: new CpIcons(),
Objectives: new TgoIcons(),
AirIcons: new AirIcons(),
});
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", {
doubleClickZoom: false,
zoomControl: false,
}).setView([0, 0], 3);
L.control.scale({ maxWidth: 200 }).addTo(map);
const rulerOptions = {
position: "topleft",
circleMarker: {
color: Colors.Highlight,
radius: 2,
},
lineStyle: {
color: Colors.Highlight,
dashArray: "1,6",
},
lengthUnit: {
display: "nm",
decimal: "2",
factor: 0.539956803,
label: "Distance:",
},
angleUnit: {
display: "°",
decimal: 0,
label: "Bearing:",
},
};
L.control.ruler(rulerOptions).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 aircraftLayer = L.layerGroup().addTo(map);
const airDefensesLayer = L.layerGroup().addTo(map);
const factoriesLayer = L.layerGroup().addTo(map);
const shipsLayer = L.layerGroup().addTo(map);
const groundObjectsLayer = L.layerGroup().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);
const combatLayer = 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();
const allFlightPlansLayer = L.layerGroup();
const blueFullThreatZones = L.layerGroup();
const blueAircraftThreatZones = L.layerGroup();
const blueAirDefenseThreatZones = L.layerGroup();
const blueRadarSamThreatZones = L.layerGroup();
const redFullThreatZones = L.layerGroup();
const redAircraftThreatZones = L.layerGroup();
const redAirDefenseThreatZones = L.layerGroup();
const redRadarSamThreatZones = L.layerGroup();
const blueNavmesh = L.layerGroup();
const redNavmesh = L.layerGroup();
const inclusionZones = L.layerGroup();
const exclusionZones = L.layerGroup();
const seaZones = L.layerGroup();
const unculledZones = L.layerGroup();
const noWaypointZones = L.layerGroup();
const ipZones = L.layerGroup();
const joinZones = L.layerGroup();
const holdZones = L.layerGroup();
const debugControlGroups = {
"Blue Threat Zones": {
Hide: L.layerGroup().addTo(map),
Full: blueFullThreatZones,
Aircraft: blueAircraftThreatZones,
"Air Defenses": blueAirDefenseThreatZones,
"Radar SAMs": blueRadarSamThreatZones,
},
"Red Threat Zones": {
Hide: L.layerGroup().addTo(map),
Full: redFullThreatZones,
Aircraft: redAircraftThreatZones,
"Air Defenses": redAirDefenseThreatZones,
"Radar SAMs": redRadarSamThreatZones,
},
Navmeshes: {
Hide: L.layerGroup().addTo(map),
Blue: blueNavmesh,
Red: redNavmesh,
},
"Map Zones": {
"Inclusion zones": inclusionZones,
"Exclusion zones": exclusionZones,
"Sea zones": seaZones,
"Culling exclusion zones": unculledZones,
},
};
if (ENABLE_EXPENSIVE_DEBUG_TOOLS) {
debugControlGroups["Waypoint Zones"] = {
None: noWaypointZones,
"IP Zones": ipZones,
"Join Zones": joinZones,
"Hold Zones": holdZones,
};
}
// Main map controls. These are the ones that we expect users to interact with.
// These are always open, which unfortunately means that the scroll bar will not
// appear if the menu doesn't fit. This fits in the smallest window size we
// allow, but may need to start auto-collapsing it (or fix the plugin to add a
// scrollbar when non-collapsing) if it gets much larger.
L.control
.groupedLayers(
baseLayers,
{
"Units and locations": {
"Control points": controlPointsLayer,
Aircraft: aircraftLayer,
"Active combat": combatLayer,
"Air defenses": airDefensesLayer,
Factories: factoriesLayer,
Ships: shipsLayer,
"Other ground objects": groundObjectsLayer,
"Supply routes": supplyRoutesLayer,
"Front lines": frontLinesLayer,
},
"Enemy Air Defenses": {
"Enemy SAM threat range": redSamThreatLayer,
"Enemy SAM detection range": redSamDetectionLayer,
},
"Allied Air Defenses": {
"Ally SAM threat range": blueSamThreatLayer,
"Ally SAM detection range": blueSamDetectionLayer,
},
"Flight Plans": {
Hide: L.layerGroup(),
"Show selected blue": selectedFlightPlansLayer,
"Show all blue": blueFlightPlansLayer,
"Show all red": redFlightPlansLayer,
"Show all": allFlightPlansLayer,
},
},
{
collapsed: false,
exclusiveGroups: ["Flight Plans"],
groupCheckboxes: true,
}
)
.addTo(map);
// Debug map controls. Hover over to open. Not something most users will want or
// need to interact with.
L.control
.groupedLayers(null, debugControlGroups, {
position: "topleft",
exclusiveGroups: [
"Blue Threat Zones",
"Red Threat Zones",
"Navmeshes",
"Waypoint Zones",
],
groupCheckboxes: true,
})
.addTo(map);
let game;
new QWebChannel(qt.webChannelTransport, function (channel) {
const ws = new WebSocket(WS_BACKEND);
ws.addEventListener("message", (event) => {
handleStreamedEvents(JSON.parse(event.data));
});
ws.addEventListener("close", (event) => {
console.log(`Websocket closed: ${event}`);
});
ws.addEventListener("error", (error) => {
console.log(`Websocket error: ${error}`);
});
game = channel.objects.game;
API_KEY = game.apiKey;
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(drawAircraft);
game.selectedFlightChanged.connect(updateSelectedFlight);
});
function handleStreamedEvents(events) {
for (const [flightId, position] of Object.entries(events.updated_flights)) {
Flight.withId(flightId).drawAircraftLocation(position);
}
for (const combat of events.new_combats) {
redrawCombat(combat);
}
for (const combat of events.updated_combats) {
redrawCombat(combat);
}
for (const player of events.navmesh_updates) {
drawNavmesh(player);
}
if (events.unculled_zones_updated) {
drawUnculledZones();
}
if (events.threat_zones_updated) {
drawThreatZones();
}
}
function recenterMap(center) {
map.setView(center, 8, { animate: true, duration: 1 });
}
function updateSelectedFlight(id) {
if (id == null) {
holdZones.clearLayers();
ipZones.clearLayers();
joinZones.clearLayers();
return;
}
drawHoldZones(id);
drawIpZones(id);
drawJoinZones(id);
}
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());
}
icon() {
return Icons.ControlPoints.icon(
this.cp.category,
this.cp.blue,
this.cp.status
);
}
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 locationMarker = this.locationMarker(dragging);
const destinationMarker = this.destinationMarker();
locationMarker
.bindTooltip(`
${this.cp.name}
`)
.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: this.icon(),
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: this.icon(),
zIndexOffset: 1000,
});
}
makePath() {
const destination = this.hasDestination() ? this.cp.destination : [0, 0];
return L.polyline([this.cp.position, destination], {
color: Colors.Green,
weight: 1,
interactive: false,
});
}
onDestinationChanged() {
if (this.hasDestination()) {
this.primaryMarker.setLatLng(this.cp.destination);
this.primaryMarker.setOpacity(0.5);
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.primaryMarker.setOpacity(1);
}
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();
});
}
class TheaterGroundObject {
constructor(tgo) {
this.tgo = tgo;
}
samIsThreat() {
for (const range of this.tgo.samThreatRanges) {
if (range > 0) {
return true;
}
}
return false;
}
icon() {
let state;
if (this.tgo.dead) {
state = UnitState.Destroyed;
} else if (this.tgo.category == "aa" && !this.samIsThreat()) {
state = UnitState.Damaged;
} else {
state = UnitState.Alive;
}
return Icons.Objectives.icon(this.tgo.category, this.tgo.blue, state);
}
layer() {
switch (this.tgo.category) {
case "aa":
return airDefensesLayer;
case "factory":
return factoriesLayer;
case "ship":
return shipsLayer;
default:
return groundObjectsLayer;
}
}
drawSamThreats() {
const detectionLayer = this.tgo.blue
? blueSamDetectionLayer
: redSamDetectionLayer;
const threatLayer = this.tgo.blue ? blueSamThreatLayer : redSamThreatLayer;
const threatColor = this.tgo.blue ? Colors.Blue : Colors.Red;
const detectionColor = this.tgo.blue ? "#bb89ff" : "#eee17b";
this.tgo.samDetectionRanges.forEach((range) => {
L.circle(this.tgo.position, {
radius: range,
color: detectionColor,
fill: false,
weight: 1,
interactive: false,
}).addTo(detectionLayer);
});
this.tgo.samThreatRanges.forEach((range) => {
L.circle(this.tgo.position, {
radius: range,
color: threatColor,
fill: false,
weight: 2,
interactive: false,
}).addTo(threatLayer);
});
}
draw() {
if (!this.tgo.blue && this.tgo.dead) {
// Don't bother drawing dead opfor TGOs. Blue is worth showing because
// some of them can be repaired, but the player can't interact with dead
// red things so there's no point in showing them.
return;
}
L.marker(this.tgo.position, { icon: this.icon() })
.bindTooltip(
`${this.tgo.name} (${
this.tgo.controlPointName
})
${this.tgo.units.join("
")}`
)
.on("click", () => this.tgo.showInfoDialog())
.on("contextmenu", () => this.tgo.showPackageDialog())
.addTo(this.layer());
this.drawSamThreats();
}
}
function drawGroundObjects() {
airDefensesLayer.clearLayers();
factoriesLayer.clearLayers();
shipsLayer.clearLayers();
groundObjectsLayer.clearLayers();
blueSamDetectionLayer.clearLayers();
redSamDetectionLayer.clearLayers();
blueSamThreatLayer.clearLayers();
redSamThreatLayer.clearLayers();
game.groundObjects.forEach((tgo) => {
new TheaterGroundObject(tgo).draw();
});
}
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";
}
const line = L.polyline(route.points, {
color: color,
weight: route.isSea ? 4 : 6,
}).addTo(supplyRoutesLayer);
const activeTransports = route.activeTransports;
if (activeTransports.length > 0) {
line.bindTooltip(activeTransports.join("
"));
L.polyline(route.points, {
color: "#ffffff",
weight: 2,
}).addTo(supplyRoutesLayer);
} else {
line.bindTooltip("This supply route is inactive.");
}
});
}
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, number, flight) {
this.waypoint = waypoint;
this.number = number;
this.flight = flight;
this.marker = this.makeMarker();
}
position() {
return this.waypoint.position;
}
shouldMark() {
return this.waypoint.should_mark;
}
async timing(dragging) {
if (dragging) {
return "Waiting to recompute TOT...";
}
return await getJson(`/waypoints/${this.flight.id}/${this.number}/timing`);
}
async description(dragging) {
const alt = this.waypoint.altitude_ft;
const altRef = this.waypoint.altitude_reference;
return (
`${this.number} ${this.waypoint.name}
` +
`${alt} ft ${altRef}
` +
`${await this.timing(dragging)}`
);
}
relocate() {
this.marker.setLatLng(this.position());
}
updateDescription(dragging) {
this.description(dragging).then((description) => {
this.marker.setTooltipContent(description);
});
}
makeMarker() {
const zoom = map.getZoom();
const marker = L.marker(this.position(), {
draggable: this.waypoint.is_movable,
})
.on("dragstart", (e) => {
this.updateDescription(true);
})
.on("drag", (e) => {
const marker = e.target;
const destination = marker.getLatLng();
this.flight.updatePath(this.number, destination);
})
.on("dragend", (e) => {
const marker = e.target;
const destination = marker.getLatLng();
postJson(
`/waypoints/${this.flight.id}/${this.number}/position`,
destination
)
.then(() => {
this.waypoint.position = destination;
this.updateDescription(false);
this.flight.drawCommitBoundary();
})
.catch((err) => {
if (err) {
this.relocate();
console.log(err);
marker.bindPopup(`${err}`).openPopup();
}
});
});
this.description(false).then((description) =>
marker.bindTooltip(description, {
permanent: zoom >= SHOW_WAYPOINT_INFO_AT_ZOOM,
})
);
return marker;
}
includeInPath() {
return this.waypoint.include_in_path;
}
}
class Flight {
static registeredFlights = {};
constructor(flight) {
this.flight = flight;
this.id = flight.id;
this.aircraft = null;
this.path = null;
this.markers = [];
this.commitBoundary = null;
this.flight.selectedChanged.connect(() => this.draw());
Flight.registerFlight(this);
}
static clearRegisteredFlights() {
Flight.registeredFlights = {};
}
static registerFlight(flight) {
Flight.registeredFlights[flight.id] = flight;
}
static withId(id) {
return Flight.registeredFlights[id];
}
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,
interactive: false,
})
.addTo(selectedFlightPlansLayer)
.addTo(layer)
.addTo(allFlightPlansLayer);
} else {
this.path = L.polyline(path, { color: color, interactive: false })
.addTo(layer)
.addTo(allFlightPlansLayer);
}
}
draw() {
this.drawAircraftLocation();
this.drawFlightPlan();
this.drawCommitBoundary();
}
drawAircraftLocation(position = null) {
if (this.aircraft != null) {
this.aircraft.removeFrom(aircraftLayer);
this.aircraft = null;
}
if (position == null) {
position = this.flight.position;
}
if (position.length > 0) {
this.aircraft = L.marker(position, {
icon: Icons.AirIcons.icon(
"fighter",
this.flight.blue,
this.flight.selected
),
}).addTo(aircraftLayer);
}
}
drawCommitBoundary() {
if (this.commitBoundary != null) {
this.commitBoundary
.removeFrom(selectedFlightPlansLayer)
.removeFrom(this.flightPlanLayer())
.removeFrom(allFlightPlansLayer);
}
if (this.flight.selected) {
getJson(`/flights/${this.flight.id}/commit-boundary`).then((boundary) => {
if (boundary) {
this.commitBoundary = L.polyline(boundary, {
color: Colors.Highlight,
weight: 1,
interactive: false,
})
.addTo(selectedFlightPlansLayer)
.addTo(this.flightPlanLayer())
.addTo(allFlightPlansLayer);
}
});
}
}
clearFlightPlan() {
for (const marker of this.markers) {
marker
.removeFrom(selectedFlightPlansLayer)
.removeFrom(this.flightPlanLayer())
.removeFrom(allFlightPlansLayer);
}
this.markers = [];
if (this.path != null) {
this.path
.removeFrom(selectedFlightPlansLayer)
.removeFrom(this.flightPlanLayer())
.removeFrom(allFlightPlansLayer);
}
}
drawFlightPlan() {
this.clearFlightPlan();
this.flight.flightIsInAto().then((inAto) => {
if (!inAto) {
// HACK: The signal to redraw the ATO following package/flight deletion
// and the signal to change flight/package selection due to UI selection
// change come in an arbitrary order. If redraw signal comes first the
// UI will clear the map and redraw, but then when the UI updates
// selection away from the (now deleted) flight/package it calls
// deselect, which redraws the deleted flight plan in its deselected
// state.
//
// Avoid this by checking that the flight is still in the coalition's
// ATO before drawing.
return;
}
getJson(`/waypoints/${this.flight.id}`).then((waypoints) => {
const path = [];
waypoints.map((raw, idx) => {
const waypoint = new Waypoint(raw, idx, this);
if (waypoint.includeInPath()) {
path.push(waypoint.position());
}
if (this.shouldMark(waypoint)) {
waypoint.marker
.addTo(selectedFlightPlansLayer)
.addTo(this.flightPlanLayer())
.addTo(allFlightPlansLayer);
this.markers.push(waypoint.marker);
}
});
this.drawPath(path);
});
});
}
}
function drawAircraft() {
Flight.clearRegisteredFlights();
aircraftLayer.clearLayers();
blueFlightPlansLayer.clearLayers();
redFlightPlansLayer.clearLayers();
selectedFlightPlansLayer.clearLayers();
allFlightPlansLayer.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 _drawThreatZones(zones, layer, player) {
const color = player ? Colors.Blue : Colors.Red;
for (const zone of zones) {
L.polyline(zone, {
color: color,
weight: 1,
fill: true,
fillOpacity: 0.4,
noClip: true,
interactive: false,
}).addTo(layer);
}
}
function drawThreatZones() {
blueFullThreatZones.clearLayers();
blueAircraftThreatZones.clearLayers();
blueAirDefenseThreatZones.clearLayers();
blueRadarSamThreatZones.clearLayers();
redFullThreatZones.clearLayers();
redAircraftThreatZones.clearLayers();
redAirDefenseThreatZones.clearLayers();
redRadarSamThreatZones.clearLayers();
getJson("/map-zones/threats").then((threats) => {
_drawThreatZones(threats.blue.full, blueFullThreatZones, true);
_drawThreatZones(threats.blue.aircraft, blueAircraftThreatZones, true);
_drawThreatZones(
threats.blue.air_defenses,
blueAirDefenseThreatZones,
true
);
_drawThreatZones(threats.blue.radar_sams, blueRadarSamThreatZones, true);
_drawThreatZones(threats.red.full, redFullThreatZones, false);
_drawThreatZones(threats.red.aircraft, redAircraftThreatZones, false);
_drawThreatZones(threats.red.air_defenses, redAirDefenseThreatZones, false);
_drawThreatZones(threats.red.radar_sams, redRadarSamThreatZones, false);
});
}
function drawNavmesh(player) {
const layer = player ? blueNavmesh : redNavmesh;
layer.clearLayers();
getJson(`/navmesh?for_player=${player}`).then((zones) => {
for (const zone of zones) {
L.polyline(zone.poly, {
color: "#000000",
weight: 1,
fillColor: zone.threatened ? "#ff0000" : "#00ff00",
fill: true,
fillOpacity: 0.1,
noClip: true,
interactive: false,
}).addTo(layer);
}
});
}
function drawNavmeshes() {
drawNavmesh(true);
drawNavmesh(false);
}
function drawMapZones() {
seaZones.clearLayers();
inclusionZones.clearLayers();
exclusionZones.clearLayers();
getJson("/map-zones/terrain").then((zones) => {
for (const zone of zones.sea) {
L.polygon(zone, {
color: "#344455",
fillColor: "#344455",
fillOpacity: 1,
interactive: false,
}).addTo(seaZones);
}
for (const zone of zones.inclusion) {
L.polygon(zone, {
color: "#969696",
fillColor: "#4b4b4b",
fillOpacity: 1,
interactive: false,
}).addTo(inclusionZones);
}
for (const zone of zones.exclusion) {
L.polygon(zone, {
color: "#969696",
fillColor: "#303030",
fillOpacity: 1,
interactive: false,
}).addTo(exclusionZones);
}
});
}
function drawUnculledZones() {
unculledZones.clearLayers();
getJson("/map-zones/unculled").then((zones) => {
for (const zone of zones) {
L.circle(zone.position, {
radius: zone.radius,
color: "#b4ff8c",
fill: false,
interactive: false,
}).addTo(unculledZones);
}
});
}
function drawIpZones(id) {
ipZones.clearLayers();
if (!ENABLE_EXPENSIVE_DEBUG_TOOLS) {
return;
}
getJson(`/debug/waypoint-geometries/ip/${id}`).then((iz) => {
L.polygon(iz.homeBubble, {
color: Colors.Highlight,
fillOpacity: 0.1,
interactive: false,
}).addTo(ipZones);
L.polygon(iz.ipBubble, {
color: "#bb89ff",
fillOpacity: 0.1,
interactive: false,
}).addTo(ipZones);
L.polygon(iz.permissibleZone, {
color: "#ffffff",
fillOpacity: 0.1,
interactive: false,
}).addTo(ipZones);
for (const zone of iz.safeZones) {
L.polygon(zone, {
color: Colors.Green,
fillOpacity: 0.1,
interactive: false,
}).addTo(ipZones);
}
});
}
function drawJoinZones(id) {
joinZones.clearLayers();
if (!ENABLE_EXPENSIVE_DEBUG_TOOLS) {
return;
}
getJson(`/debug/waypoint-geometries/join/${id}`).then((jz) => {
L.polygon(jz.homeBubble, {
color: Colors.Highlight,
fillOpacity: 0.1,
interactive: false,
}).addTo(joinZones);
L.polygon(jz.targetBubble, {
color: "#bb89ff",
fillOpacity: 0.1,
interactive: false,
}).addTo(joinZones);
L.polygon(jz.ipBubble, {
color: "#ffffff",
fillOpacity: 0.1,
interactive: false,
}).addTo(joinZones);
for (const zone of jz.excludedZones) {
L.polygon(zone, {
color: "#ffa500",
fillOpacity: 0.2,
stroke: false,
interactive: false,
}).addTo(joinZones);
}
for (const zone of jz.permissibleZones) {
L.polygon(zone, {
color: Colors.Green,
interactive: false,
}).addTo(joinZones);
}
for (const line of jz.preferredLines) {
L.polyline(line, {
color: Colors.Green,
interactive: false,
}).addTo(joinZones);
}
});
}
function drawHoldZones(id) {
holdZones.clearLayers();
if (!ENABLE_EXPENSIVE_DEBUG_TOOLS) {
return;
}
getJson(`/debug/waypoint-geometries/hold/${id}`).then((hz) => {
L.polygon(hz.homeBubble, {
color: Colors.Highlight,
fillOpacity: 0.1,
interactive: false,
}).addTo(holdZones);
L.polygon(hz.targetBubble, {
color: Colors.Highlight,
fillOpacity: 0.1,
interactive: false,
}).addTo(holdZones);
L.polygon(hz.joinBubble, {
color: Colors.Highlight,
fillOpacity: 0.1,
interactive: false,
}).addTo(holdZones);
for (const zone of hz.excludedZones) {
L.polygon(zone, {
color: "#ffa500",
fillOpacity: 0.2,
stroke: false,
interactive: false,
}).addTo(holdZones);
}
for (const zone of hz.permissibleZones) {
L.polygon(zone, {
color: Colors.Green,
interactive: false,
}).addTo(holdZones);
}
for (const line of hz.preferredLines) {
L.polyline(line, {
color: Colors.Green,
interactive: false,
}).addTo(holdZones);
}
});
}
var COMBATS = {};
function redrawCombat(combat) {
if (combat.id in COMBATS) {
for (layer in COMBATS[combat.id]) {
layer.removeFrom(combatLayer);
}
}
const layers = [];
if (combat.footprint) {
layers.push(
L.polygon(airCombat.footprint, {
color: Colors.Red,
interactive: false,
fillOpacity: 0.2,
}).addTo(combatLayer)
);
}
if (combat.flight_position) {
for (target_position of combat.target_positions) {
layers.push(
L.polyline([combat.flight_position, target_position], {
color: Colors.Red,
interactive: false,
}).addTo(combatLayer)
);
}
}
COMBATS[combat.id] = layers;
}
function drawInitialMap() {
recenterMap(game.mapCenter);
drawControlPoints();
drawGroundObjects();
drawSupplyRoutes();
drawFrontLines();
drawAircraft();
drawThreatZones();
drawNavmeshes();
drawMapZones();
drawUnculledZones();
}
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(selectedFlightPlansLayer, SHOW_WAYPOINT_INFO_AT_ZOOM);