mirror of
https://git.quad4.io/RNS-Things/MeshChatX.git
synced 2026-08-28 05:24:45 +00:00
feat(map): introduce geodesy functions, map link utilities, and tile network management for updated mapping capabilities
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
|
||||
/** GeoJSON / feature properties for icons and strokes (namespaced for export fidelity). */
|
||||
export const MCX_ICON_HREF = "mcx_icon_href";
|
||||
export const MCX_ICON_DATA_URL = "mcx_icon_data_url";
|
||||
export const MCX_ICON_SCALE = "mcx_icon_scale";
|
||||
export const MCX_ICON_ANCHOR_X = "mcx_icon_anchor_x";
|
||||
export const MCX_ICON_ANCHOR_Y = "mcx_icon_anchor_y";
|
||||
export const MCX_STROKE_COLOR = "mcx_stroke_color";
|
||||
export const MCX_STROKE_WIDTH = "mcx_stroke_width";
|
||||
export const MCX_FILL_COLOR = "mcx_fill_color";
|
||||
export const MCX_FILL_OPACITY = "mcx_fill_opacity";
|
||||
@@ -0,0 +1,56 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
|
||||
import GeoJSON from "ol/format/GeoJSON";
|
||||
import { normalizeFeatureMetadataProps } from "./metadataUtils.js";
|
||||
import { copyStyleMetadataToProperties, styleFromMcxProperties } from "./styleFromProperties.js";
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @param {import("ol/proj").ProjectionLike} featureProjection
|
||||
* @returns {import("ol/Feature").default[]}
|
||||
*/
|
||||
export function readGeoJsonToFeatures(text, featureProjection) {
|
||||
const format = new GeoJSON();
|
||||
const features = format.readFeatures(text, {
|
||||
dataProjection: "EPSG:4326",
|
||||
featureProjection,
|
||||
});
|
||||
for (const f of features) {
|
||||
normalizeFeatureMetadataProps(f);
|
||||
if (!f.getStyle()) {
|
||||
const s = styleFromMcxProperties(f);
|
||||
if (s) {
|
||||
f.setStyle(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
return features;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("ol/Feature").default[]} features
|
||||
* @param {import("ol/proj").ProjectionLike} featureProjection
|
||||
* @returns {string}
|
||||
*/
|
||||
export function writeFeaturesToGeoJson(features, featureProjection) {
|
||||
const format = new GeoJSON();
|
||||
for (const f of features) {
|
||||
let st = f.getStyle();
|
||||
if (typeof st === "function") {
|
||||
st = null;
|
||||
}
|
||||
if (st) {
|
||||
copyStyleMetadataToProperties(st, f);
|
||||
} else {
|
||||
const built = styleFromMcxProperties(f);
|
||||
if (built) {
|
||||
copyStyleMetadataToProperties(built, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
return format.writeFeatures(features, {
|
||||
dataProjection: "EPSG:4326",
|
||||
featureProjection,
|
||||
decimals: 7,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
|
||||
import KML from "ol/format/KML";
|
||||
import { normalizeFeatureMetadataProps } from "./metadataUtils.js";
|
||||
import { normalizeKmlImportedFeatures, ensureOlStylesForKmlExport } from "./styleFromProperties.js";
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @param {import("ol/proj").ProjectionLike} featureProjection
|
||||
* @returns {import("ol/Feature").default[]}
|
||||
*/
|
||||
export function readKmlToFeatures(text, featureProjection) {
|
||||
const format = new KML({
|
||||
extractStyles: true,
|
||||
showNetworkLinks: false,
|
||||
showPointNames: false,
|
||||
});
|
||||
const features = format.readFeatures(text, {
|
||||
dataProjection: "EPSG:4326",
|
||||
featureProjection,
|
||||
});
|
||||
normalizeKmlImportedFeatures(features);
|
||||
for (const f of features) {
|
||||
normalizeFeatureMetadataProps(f);
|
||||
}
|
||||
return features;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("ol/Feature").default[]} features
|
||||
* @param {import("ol/proj").ProjectionLike} featureProjection
|
||||
* @returns {string}
|
||||
*/
|
||||
export function writeFeaturesToKml(features, featureProjection) {
|
||||
const format = new KML();
|
||||
ensureOlStylesForKmlExport(features);
|
||||
return format.writeFeatures(features, {
|
||||
dataProjection: "EPSG:4326",
|
||||
featureProjection,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
|
||||
import JSZip from "jszip";
|
||||
import { readKmlToFeatures, writeFeaturesToKml } from "./kmlCodec.js";
|
||||
|
||||
/**
|
||||
* @param {Uint8Array} u8
|
||||
* @returns {string}
|
||||
*/
|
||||
function uint8ToBase64(u8) {
|
||||
const CHUNK = 0x8000;
|
||||
let binary = "";
|
||||
for (let i = 0; i < u8.length; i += CHUNK) {
|
||||
binary += String.fromCharCode.apply(null, u8.subarray(i, i + CHUNK));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} pathInZip
|
||||
* @returns {string}
|
||||
*/
|
||||
function guessMimeFromPath(pathInZip) {
|
||||
const ext = pathInZip.split(".").pop().toLowerCase();
|
||||
if (ext === "png") {
|
||||
return "image/png";
|
||||
}
|
||||
if (ext === "jpg" || ext === "jpeg") {
|
||||
return "image/jpeg";
|
||||
}
|
||||
if (ext === "gif") {
|
||||
return "image/gif";
|
||||
}
|
||||
if (ext === "webp") {
|
||||
return "image/webp";
|
||||
}
|
||||
if (ext === "svg") {
|
||||
return "image/svg+xml";
|
||||
}
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} mime
|
||||
* @returns {string}
|
||||
*/
|
||||
function extFromMime(mime) {
|
||||
const m = String(mime || "").toLowerCase();
|
||||
if (m.includes("png")) {
|
||||
return "png";
|
||||
}
|
||||
if (m.includes("jpeg") || m.includes("jpg")) {
|
||||
return "jpg";
|
||||
}
|
||||
if (m.includes("gif")) {
|
||||
return "gif";
|
||||
}
|
||||
if (m.includes("webp")) {
|
||||
return "webp";
|
||||
}
|
||||
if (m.includes("svg")) {
|
||||
return "svg";
|
||||
}
|
||||
return "bin";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} kmlPathInZip forward slashes, e.g. "folder/doc.kml"
|
||||
* @param {string} href
|
||||
* @returns {string|null} resolved path inside zip or null if external / invalid
|
||||
*/
|
||||
export function resolveHrefToZipPath(kmlPathInZip, href) {
|
||||
const h = String(href).trim();
|
||||
if (!h || /^(https?:|data:|file:|\/\/)/i.test(h)) {
|
||||
return null;
|
||||
}
|
||||
const base = kmlPathInZip.includes("/") ? kmlPathInZip.slice(0, kmlPathInZip.lastIndexOf("/") + 1) : "";
|
||||
const combined = (base + h).replace(/\\/g, "/");
|
||||
const segments = combined.split("/").filter((s) => s.length && s !== ".");
|
||||
const out = [];
|
||||
for (const s of segments) {
|
||||
if (s === "..") {
|
||||
if (!out.length) {
|
||||
return null;
|
||||
}
|
||||
out.pop();
|
||||
} else {
|
||||
out.push(s);
|
||||
}
|
||||
}
|
||||
return out.join("/");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("jszip").default} zip
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function findKmlEntryName(zip) {
|
||||
const names = Object.keys(zip.files).filter((n) => !zip.files[n].dir);
|
||||
const doc = names.find((n) => n.replace(/\\/g, "/").toLowerCase() === "doc.kml");
|
||||
if (doc) {
|
||||
return doc.replace(/\\/g, "/");
|
||||
}
|
||||
const kmls = names.map((n) => n.replace(/\\/g, "/")).filter((n) => n.toLowerCase().endsWith(".kml"));
|
||||
if (!kmls.length) {
|
||||
return null;
|
||||
}
|
||||
kmls.sort((a, b) => a.length - b.length);
|
||||
return kmls[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("jszip").default} zip
|
||||
* @param {string} zipPath
|
||||
* @returns {import("jszip").JSZipObject|null}
|
||||
*/
|
||||
function zipFileInsensitive(zip, zipPath) {
|
||||
const norm = zipPath.replace(/\\/g, "/");
|
||||
let f = zip.file(norm);
|
||||
if (f) {
|
||||
return f;
|
||||
}
|
||||
const want = norm.toLowerCase();
|
||||
const keys = Object.keys(zip.files);
|
||||
const hit = keys.find((k) => !zip.files[k].dir && k.replace(/\\/g, "/").toLowerCase() === want);
|
||||
return hit ? zip.file(hit) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Embed zip-local icon paths as data: URIs so blob: URLs are not required (merge-safe).
|
||||
* @param {import("jszip").default} zip
|
||||
* @param {string} kmlText
|
||||
* @param {string} kmlEntryName
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function rewriteKmlLocalHrefsToDataUrls(zip, kmlText, kmlEntryName) {
|
||||
const hrefRe = /<href>\s*([^<]+?)\s*<\/href>/gi;
|
||||
const matches = [...kmlText.matchAll(hrefRe)];
|
||||
const rawToData = new Map();
|
||||
for (const m of matches) {
|
||||
const raw = m[1].trim();
|
||||
if (rawToData.has(raw)) {
|
||||
continue;
|
||||
}
|
||||
if (/^(https?:|data:)/i.test(raw)) {
|
||||
continue;
|
||||
}
|
||||
const zipPath = resolveHrefToZipPath(kmlEntryName, raw);
|
||||
if (!zipPath) {
|
||||
continue;
|
||||
}
|
||||
const entry = zipFileInsensitive(zip, zipPath);
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
const ab = await entry.async("arraybuffer");
|
||||
const mime = guessMimeFromPath(zipPath);
|
||||
const b64 = uint8ToBase64(new Uint8Array(ab));
|
||||
rawToData.set(raw, `data:${mime};base64,${b64}`);
|
||||
}
|
||||
return kmlText.replace(hrefRe, (full, inner) => {
|
||||
const raw = inner.trim();
|
||||
const data = rawToData.get(raw);
|
||||
return data ? `<href>${data}</href>` : full;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ArrayBuffer} arrayBuffer
|
||||
* @param {import("ol/proj").ProjectionLike} featureProjection
|
||||
* @returns {Promise<import("ol/Feature").default[]>}
|
||||
*/
|
||||
export async function readKmzToFeatures(arrayBuffer, featureProjection) {
|
||||
const zip = await JSZip.loadAsync(arrayBuffer);
|
||||
const kmlName = findKmlEntryName(zip);
|
||||
if (!kmlName) {
|
||||
throw new Error("KMZ has no KML document");
|
||||
}
|
||||
const entry = zip.file(kmlName);
|
||||
if (!entry) {
|
||||
throw new Error("KMZ KML entry missing");
|
||||
}
|
||||
let kmlText = await entry.async("string");
|
||||
kmlText = await rewriteKmlLocalHrefsToDataUrls(zip, kmlText, kmlName);
|
||||
return readKmlToFeatures(kmlText, featureProjection);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("ol/Feature").default[]} features
|
||||
* @param {import("ol/proj").ProjectionLike} featureProjection
|
||||
* @returns {Promise<Blob>}
|
||||
*/
|
||||
export async function writeFeaturesToKmzBlob(features, featureProjection) {
|
||||
let kml = writeFeaturesToKml(features, featureProjection);
|
||||
const zip = new JSZip();
|
||||
let n = 0;
|
||||
const dataUriRe = /<href>\s*(data:([^;]+);base64,([^<\s]+))\s*<\/href>/gi;
|
||||
kml = kml.replace(dataUriRe, (full, _dataUri, mime, b64) => {
|
||||
const ext = extFromMime(mime);
|
||||
const path = `files/mcx-embedded-${n++}.${ext}`;
|
||||
let bin;
|
||||
try {
|
||||
const binary = atob(String(b64).trim());
|
||||
bin = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bin[i] = binary.charCodeAt(i);
|
||||
}
|
||||
} catch {
|
||||
return full;
|
||||
}
|
||||
zip.file(path, bin);
|
||||
return `<href>${path}</href>`;
|
||||
});
|
||||
zip.file("doc.kml", kml);
|
||||
return zip.generateAsync({ type: "blob", compression: "DEFLATE" });
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
|
||||
import { getCenter } from "ol/extent";
|
||||
import {
|
||||
MCX_FILL_COLOR,
|
||||
MCX_FILL_OPACITY,
|
||||
MCX_ICON_ANCHOR_X,
|
||||
MCX_ICON_ANCHOR_Y,
|
||||
MCX_ICON_DATA_URL,
|
||||
MCX_ICON_HREF,
|
||||
MCX_ICON_SCALE,
|
||||
MCX_STROKE_COLOR,
|
||||
MCX_STROKE_WIDTH,
|
||||
} from "./constants.js";
|
||||
|
||||
const SKIP_EXTENDED = new Set([
|
||||
"geometry",
|
||||
"type",
|
||||
"note",
|
||||
"telemetry",
|
||||
"discovered",
|
||||
"cluster",
|
||||
"peer",
|
||||
"segmentKind",
|
||||
"bearingMetrics",
|
||||
"_measureOverlay",
|
||||
MCX_ICON_DATA_URL,
|
||||
MCX_ICON_HREF,
|
||||
MCX_ICON_SCALE,
|
||||
MCX_ICON_ANCHOR_X,
|
||||
MCX_ICON_ANCHOR_Y,
|
||||
MCX_STROKE_COLOR,
|
||||
MCX_STROKE_WIDTH,
|
||||
MCX_FILL_COLOR,
|
||||
MCX_FILL_OPACITY,
|
||||
"marker-color",
|
||||
"marker-size",
|
||||
"stroke",
|
||||
"stroke-width",
|
||||
"fill",
|
||||
"fill-opacity",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Normalize KML-style Name/Description onto lowercase keys for export.
|
||||
* @param {import("ol/Feature").default} feature
|
||||
*/
|
||||
export function normalizeFeatureMetadataProps(feature) {
|
||||
if (!feature) {
|
||||
return;
|
||||
}
|
||||
const n = feature.get("name");
|
||||
const N = feature.get("Name");
|
||||
if ((n == null || n === "") && N != null && N !== "") {
|
||||
feature.set("name", N);
|
||||
}
|
||||
const d = feature.get("description");
|
||||
const D = feature.get("Description");
|
||||
if ((d == null || d === "") && D != null && D !== "") {
|
||||
feature.set("description", D);
|
||||
}
|
||||
const t = feature.get("title");
|
||||
const nameAfterKml = feature.get("name");
|
||||
if ((nameAfterKml == null || nameAfterKml === "") && t != null && t !== "") {
|
||||
feature.set("name", typeof t === "string" ? t : String(t));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("ol/Feature").default} feature
|
||||
* @returns {import("ol/coordinate").Coordinate|null}
|
||||
*/
|
||||
export function getFeatureAnchorCoordinate(feature) {
|
||||
const g = feature.getGeometry();
|
||||
if (!g) {
|
||||
return null;
|
||||
}
|
||||
const t = g.getType();
|
||||
if (t === "Point") {
|
||||
return /** @type {import("ol/geom/Point").default} */ (g).getCoordinates();
|
||||
}
|
||||
if (t === "MultiPoint") {
|
||||
return /** @type {import("ol/geom/MultiPoint").default} */ (g).getPoint(0).getCoordinates();
|
||||
}
|
||||
if (t === "Polygon") {
|
||||
return /** @type {import("ol/geom/Polygon").default} */ (g).getInteriorPoint().getCoordinates();
|
||||
}
|
||||
if (t === "MultiPolygon") {
|
||||
const mp = /** @type {import("ol/geom/MultiPolygon").default} */ (g);
|
||||
return mp.getPolygon(0).getInteriorPoint().getCoordinates();
|
||||
}
|
||||
if (t === "LineString") {
|
||||
const c = /** @type {import("ol/geom/LineString").default} */ (g).getCoordinates();
|
||||
if (!c.length) {
|
||||
return null;
|
||||
}
|
||||
return c[Math.floor(c.length / 2)];
|
||||
}
|
||||
if (t === "MultiLineString") {
|
||||
const ml = /** @type {import("ol/geom/MultiLineString").default} */ (g);
|
||||
const line = ml.getLineString(0);
|
||||
const c = line.getCoordinates();
|
||||
if (!c.length) {
|
||||
return null;
|
||||
}
|
||||
return c[Math.floor(c.length / 2)];
|
||||
}
|
||||
return getCenter(g.getExtent());
|
||||
}
|
||||
|
||||
function looksLikeHtml(s) {
|
||||
return /<\/?[a-z][\s\S]*>/i.test(s);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("ol/Feature").default} feature
|
||||
* @returns {{ name: string, description: string, descriptionIsHtml: boolean, iconSrc: string|null, extended: { key: string, value: string }[] }|null}
|
||||
*/
|
||||
export function getDrawFeatureMetadataPayload(feature) {
|
||||
if (!feature) {
|
||||
return null;
|
||||
}
|
||||
normalizeFeatureMetadataProps(feature);
|
||||
const props = feature.getProperties();
|
||||
if (props.type === "note") {
|
||||
return null;
|
||||
}
|
||||
const name = String(props.name ?? "").trim();
|
||||
const rawDesc = props.description;
|
||||
const description = rawDesc == null ? "" : typeof rawDesc === "string" ? rawDesc : String(rawDesc);
|
||||
const iconSrc = props[MCX_ICON_DATA_URL] || props[MCX_ICON_HREF] || null;
|
||||
const extended = [];
|
||||
for (const [k, v] of Object.entries(props)) {
|
||||
if (k === "geometry" || k.startsWith("_")) {
|
||||
continue;
|
||||
}
|
||||
if (SKIP_EXTENDED.has(k) || k.startsWith("mcx_")) {
|
||||
continue;
|
||||
}
|
||||
if (k === "name" || k === "Name" || k === "description" || k === "Description") {
|
||||
continue;
|
||||
}
|
||||
let vs;
|
||||
if (v == null) {
|
||||
vs = "";
|
||||
} else if (typeof v === "object") {
|
||||
try {
|
||||
vs = JSON.stringify(v);
|
||||
} catch {
|
||||
vs = String(v);
|
||||
}
|
||||
} else {
|
||||
vs = String(v);
|
||||
}
|
||||
if (vs.length > 400) {
|
||||
vs = `${vs.slice(0, 400)}…`;
|
||||
}
|
||||
extended.push({ key: k, value: vs });
|
||||
}
|
||||
extended.sort((a, b) => a.key.localeCompare(b.key));
|
||||
if (!name && !description.trim() && !extended.length && !iconSrc) {
|
||||
const geom = feature.getGeometry();
|
||||
const geomType = geom ? geom.getType() : null;
|
||||
if (geomType) {
|
||||
return {
|
||||
name: "",
|
||||
description: "",
|
||||
descriptionIsHtml: false,
|
||||
iconSrc: null,
|
||||
extended: [{ key: "geometry_type", value: geomType }],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
descriptionIsHtml: Boolean(description.trim() && looksLikeHtml(description)),
|
||||
iconSrc: iconSrc ? String(iconSrc) : null,
|
||||
extended,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
|
||||
import { Circle as CircleStyle, Fill, Icon, Stroke, Style } from "ol/style";
|
||||
import LineString from "ol/geom/LineString";
|
||||
import {
|
||||
MCX_FILL_COLOR,
|
||||
MCX_FILL_OPACITY,
|
||||
MCX_ICON_ANCHOR_X,
|
||||
MCX_ICON_ANCHOR_Y,
|
||||
MCX_ICON_DATA_URL,
|
||||
MCX_ICON_HREF,
|
||||
MCX_ICON_SCALE,
|
||||
MCX_STROKE_COLOR,
|
||||
MCX_STROKE_WIDTH,
|
||||
} from "./constants.js";
|
||||
|
||||
const SIMPLE_MARKER_COLOR = "marker-color";
|
||||
const SIMPLE_MARKER_SIZE_KEY = "marker-size";
|
||||
|
||||
const ICON_BASE_CSS_PX = 32;
|
||||
const ICON_WIDTH_MIN_PX = 8;
|
||||
const ICON_WIDTH_MAX_PX = 40;
|
||||
|
||||
function num(v, fallback) {
|
||||
const n = typeof v === "number" ? v : parseFloat(v);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
function hexToRgba(hex, alpha = 1) {
|
||||
if (!hex || typeof hex !== "string") {
|
||||
return `rgba(59,130,246,${alpha})`;
|
||||
}
|
||||
let h = hex.trim();
|
||||
if (h.startsWith("#")) {
|
||||
h = h.slice(1);
|
||||
}
|
||||
if (h.length === 3) {
|
||||
h = h
|
||||
.split("")
|
||||
.map((c) => c + c)
|
||||
.join("");
|
||||
}
|
||||
if (h.length !== 6) {
|
||||
return `rgba(59,130,246,${alpha})`;
|
||||
}
|
||||
const r = parseInt(h.slice(0, 2), 16);
|
||||
const g = parseInt(h.slice(2, 4), 16);
|
||||
const b = parseInt(h.slice(4, 6), 16);
|
||||
return `rgba(${r},${g},${b},${alpha})`;
|
||||
}
|
||||
|
||||
function circleRadiusFromSimpleSize(markerSize) {
|
||||
const s = String(markerSize || "medium").toLowerCase();
|
||||
if (s === "small") {
|
||||
return 5;
|
||||
}
|
||||
if (s === "large") {
|
||||
return 11;
|
||||
}
|
||||
return 8;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an OpenLayers Style from MeshChatX / simplestyle-ish feature properties.
|
||||
* Used when the feature has no per-feature style (e.g. GeoJSON import).
|
||||
* @param {import("ol/Feature").default} feature
|
||||
* @returns {import("ol/style/Style").default|null}
|
||||
*/
|
||||
/**
|
||||
* Replace OL KML icon styles with a capped MCX icon style when metadata is present.
|
||||
* Avoids full-resolution bitmaps when scale was captured before the image finished loading.
|
||||
* @param {import("ol/Feature").default} feature
|
||||
*/
|
||||
export function applyCappedMcxIconStyleIfNeeded(feature) {
|
||||
const g = feature.getGeometry();
|
||||
if (!g) {
|
||||
return;
|
||||
}
|
||||
const t = g.getType();
|
||||
if (t !== "Point" && t !== "MultiPoint") {
|
||||
return;
|
||||
}
|
||||
const p = feature.getProperties();
|
||||
if (!(p[MCX_ICON_DATA_URL] || p[MCX_ICON_HREF])) {
|
||||
return;
|
||||
}
|
||||
const built = styleFromMcxProperties(feature);
|
||||
if (built) {
|
||||
feature.setStyle(built);
|
||||
}
|
||||
}
|
||||
|
||||
export function styleFromMcxProperties(feature) {
|
||||
const geom = feature.getGeometry();
|
||||
if (!geom) {
|
||||
return null;
|
||||
}
|
||||
const p = feature.getProperties();
|
||||
const type = geom.getType();
|
||||
|
||||
const iconSrc = p[MCX_ICON_DATA_URL] || p[MCX_ICON_HREF];
|
||||
if (iconSrc && (type === "Point" || type === "MultiPoint")) {
|
||||
const factor = num(p[MCX_ICON_SCALE], 1);
|
||||
const widthPx = Math.round(Math.min(ICON_WIDTH_MAX_PX, Math.max(ICON_WIDTH_MIN_PX, ICON_BASE_CSS_PX * factor)));
|
||||
const ax = num(p[MCX_ICON_ANCHOR_X], 0.5);
|
||||
const ay = num(p[MCX_ICON_ANCHOR_Y], 1);
|
||||
const isData = String(iconSrc).startsWith("data:");
|
||||
return new Style({
|
||||
image: new Icon({
|
||||
src: iconSrc,
|
||||
width: widthPx,
|
||||
anchor: [ax, ay],
|
||||
crossOrigin: isData ? undefined : "anonymous",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (type === "Point" || type === "MultiPoint") {
|
||||
const mc = p[SIMPLE_MARKER_COLOR] || p["marker-color"];
|
||||
if (mc) {
|
||||
const r = circleRadiusFromSimpleSize(p[SIMPLE_MARKER_SIZE_KEY]);
|
||||
return new Style({
|
||||
image: new CircleStyle({
|
||||
radius: r,
|
||||
fill: new Fill({ color: hexToRgba(mc, 0.85) }),
|
||||
stroke: new Stroke({ color: "#1f2937", width: 1 }),
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const strokeRaw = p[MCX_STROKE_COLOR] ?? p.stroke ?? "#2563eb";
|
||||
const strokeWidth = num(p[MCX_STROKE_WIDTH] ?? p["stroke-width"], 2);
|
||||
const fillRaw = p[MCX_FILL_COLOR] ?? p.fill;
|
||||
const fillOpacity = num(p[MCX_FILL_OPACITY] ?? p["fill-opacity"], fillRaw ? 0.35 : 0);
|
||||
|
||||
const stroke = new Stroke({
|
||||
color: /** @type {import("ol/color").Color|string} */ (strokeRaw),
|
||||
width: strokeWidth,
|
||||
});
|
||||
|
||||
if (type === "LineString" || type === "MultiLineString") {
|
||||
return new Style({ stroke });
|
||||
}
|
||||
|
||||
if (type === "Polygon" || type === "MultiPolygon") {
|
||||
let fill;
|
||||
if (fillRaw) {
|
||||
fill =
|
||||
typeof fillRaw === "string"
|
||||
? new Fill({
|
||||
color: hexToRgba(fillRaw, fillOpacity > 0 ? fillOpacity : 0.35),
|
||||
})
|
||||
: new Fill({ color: /** @type {import("ol/color").Color} */ (fillRaw) });
|
||||
} else {
|
||||
fill = new Fill({ color: "rgba(59, 130, 246, 0.2)" });
|
||||
}
|
||||
return new Style({ stroke, fill });
|
||||
}
|
||||
|
||||
if (type === "Circle") {
|
||||
const g = /** @type {import("ol/geom/Circle").default} */ (geom);
|
||||
const center = g.getCenter();
|
||||
const edge = [center[0] + g.getRadius(), center[1]];
|
||||
const line = new LineString([center, edge]);
|
||||
return new Style({
|
||||
stroke,
|
||||
geometry: line,
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy icon/stroke metadata from an OpenLayers Style into feature properties for GeoJSON export.
|
||||
* @param {import("ol/style/Style").default} style
|
||||
* @param {import("ol/Feature").default} feature
|
||||
*/
|
||||
export function copyStyleMetadataToProperties(style, feature) {
|
||||
if (!style || !feature) {
|
||||
return;
|
||||
}
|
||||
const styles = Array.isArray(style) ? style : [style];
|
||||
for (const st of styles) {
|
||||
if (!st || typeof st.getImage !== "function") {
|
||||
continue;
|
||||
}
|
||||
const img = st.getImage();
|
||||
if (img && typeof img.getSrc === "function") {
|
||||
const src = img.getSrc();
|
||||
if (src) {
|
||||
if (String(src).startsWith("data:")) {
|
||||
feature.set(MCX_ICON_DATA_URL, src);
|
||||
} else {
|
||||
feature.set(MCX_ICON_HREF, src);
|
||||
}
|
||||
const sc = img.getScale();
|
||||
if (sc != null) {
|
||||
feature.set(MCX_ICON_SCALE, sc);
|
||||
}
|
||||
const anchor = img.getAnchor && img.getAnchor();
|
||||
if (anchor && anchor.length >= 2) {
|
||||
feature.set(MCX_ICON_ANCHOR_X, anchor[0]);
|
||||
feature.set(MCX_ICON_ANCHOR_Y, anchor[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (typeof st.getStroke === "function") {
|
||||
const s = st.getStroke();
|
||||
if (s && s.getColor()) {
|
||||
feature.set(MCX_STROKE_COLOR, s.getColor());
|
||||
feature.set(MCX_STROKE_WIDTH, s.getWidth());
|
||||
}
|
||||
}
|
||||
if (typeof st.getFill === "function") {
|
||||
const f = st.getFill();
|
||||
if (f && f.getColor()) {
|
||||
feature.set(MCX_FILL_COLOR, f.getColor());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After KML import, mirror style into mcx_* props so GeoJSON export keeps icons.
|
||||
* @param {import("ol/Feature").default[]} features
|
||||
*/
|
||||
export function normalizeKmlImportedFeatures(features) {
|
||||
for (const f of features) {
|
||||
let st = f.getStyle();
|
||||
if (typeof st === "function") {
|
||||
st = st(f);
|
||||
}
|
||||
const list = st == null ? [] : Array.isArray(st) ? st : [st];
|
||||
for (const s of list) {
|
||||
copyStyleMetadataToProperties(s, f);
|
||||
}
|
||||
applyCappedMcxIconStyleIfNeeded(f);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure each feature has an OL style for KML export when only properties were set.
|
||||
* Mutates features (sets style).
|
||||
* @param {import("ol/Feature").default[]} features
|
||||
*/
|
||||
export function ensureOlStylesForKmlExport(features) {
|
||||
for (const f of features) {
|
||||
let st = f.getStyle();
|
||||
if (typeof st === "function") {
|
||||
st = null;
|
||||
}
|
||||
if (st != null) {
|
||||
continue;
|
||||
}
|
||||
const built = styleFromMcxProperties(f);
|
||||
if (built) {
|
||||
f.setStyle(built);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
|
||||
import { DEFAULT_RADIUS, getDistance } from "ol/sphere";
|
||||
|
||||
/**
|
||||
* @param {number} lon1
|
||||
* @param {number} lat1
|
||||
* @param {number} lon2
|
||||
* @param {number} lat2
|
||||
* @returns {number} Initial (forward) azimuth in degrees [0, 360), spherical model.
|
||||
*/
|
||||
export function sphericalInitialBearingDeg(lon1, lat1, lon2, lat2) {
|
||||
const φ1 = (lat1 * Math.PI) / 180;
|
||||
const φ2 = (lat2 * Math.PI) / 180;
|
||||
const Δλ = ((lon2 - lon1) * Math.PI) / 180;
|
||||
const y = Math.sin(Δλ) * Math.cos(φ2);
|
||||
const x = Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ);
|
||||
const θ = Math.atan2(y, x);
|
||||
return ((((θ * 180) / Math.PI) % 360) + 360) % 360;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rhumb-line distance and constant bearing on the sphere (mean Earth radius).
|
||||
* @param {number} lon1
|
||||
* @param {number} lat1
|
||||
* @param {number} lon2
|
||||
* @param {number} lat2
|
||||
* @param {number} [radius]
|
||||
* @returns {{ distanceMeters: number, bearingDeg: number }}
|
||||
*/
|
||||
export function rhumbLineMetrics(lon1, lat1, lon2, lat2, radius = DEFAULT_RADIUS) {
|
||||
const R = radius;
|
||||
const φ1 = (lat1 * Math.PI) / 180;
|
||||
const φ2 = (lat2 * Math.PI) / 180;
|
||||
let Δλ = ((lon2 - lon1) * Math.PI) / 180;
|
||||
if (Δλ > Math.PI) {
|
||||
Δλ -= 2 * Math.PI;
|
||||
}
|
||||
if (Δλ < -Math.PI) {
|
||||
Δλ += 2 * Math.PI;
|
||||
}
|
||||
const Δφ = φ2 - φ1;
|
||||
const Δψ = Math.log(Math.tan(Math.PI / 4 + φ2 / 2) / Math.tan(Math.PI / 4 + φ1 / 2));
|
||||
const q = Math.abs(Δψ) > 1e-12 ? Δφ / Δψ : Math.cos(φ1);
|
||||
const dist = Math.sqrt(Δφ * Δφ + q * q * Δλ * Δλ) * R;
|
||||
const bearingDeg = ((((Math.atan2(Δλ, Δψ) * 180) / Math.PI) % 360) + 360) % 360;
|
||||
return { distanceMeters: dist, bearingDeg };
|
||||
}
|
||||
|
||||
/**
|
||||
* Great-circle (geodesic on sphere) distance matches OpenLayers {@link import("ol/sphere").getLength}
|
||||
* for a two-point line in WGS84.
|
||||
*
|
||||
* @param {number} lon1
|
||||
* @param {number} lat1
|
||||
* @param {number} lon2
|
||||
* @param {number} lat2
|
||||
* @returns {{
|
||||
* geodesicMeters: number,
|
||||
* forwardAzimuthDeg: number,
|
||||
* backAzimuthDeg: number,
|
||||
* rhumbMeters: number,
|
||||
* rhumbBearingDeg: number,
|
||||
* rhumbBackBearingDeg: number,
|
||||
* }}
|
||||
*/
|
||||
export function computeSegmentMetrics(lon1, lat1, lon2, lat2) {
|
||||
const a = [lon1, lat1];
|
||||
const b = [lon2, lat2];
|
||||
const geodesicMeters = getDistance(a, b);
|
||||
const forwardAzimuthDeg = sphericalInitialBearingDeg(lon1, lat1, lon2, lat2);
|
||||
const backAzimuthDeg = sphericalInitialBearingDeg(lon2, lat2, lon1, lat1);
|
||||
const rh = rhumbLineMetrics(lon1, lat1, lon2, lat2);
|
||||
const rhumbBackBearingDeg = (rh.bearingDeg + 180) % 360;
|
||||
return {
|
||||
geodesicMeters,
|
||||
forwardAzimuthDeg,
|
||||
backAzimuthDeg,
|
||||
rhumbMeters: rh.distanceMeters,
|
||||
rhumbBearingDeg: rh.bearingDeg,
|
||||
rhumbBackBearingDeg,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} meters
|
||||
* @returns {{ metric: string, imperial: string }}
|
||||
*/
|
||||
export function formatLengthPairMeters(meters) {
|
||||
let metric;
|
||||
let imperial;
|
||||
if (meters > 100) {
|
||||
metric = `${Math.round((meters / 1000) * 100) / 100} km`;
|
||||
} else {
|
||||
metric = `${Math.round(meters * 100) / 100} m`;
|
||||
}
|
||||
const feet = meters * 3.28084;
|
||||
if (feet > 5280) {
|
||||
imperial = `${Math.round(meters * 0.000621371 * 100) / 100} mi`;
|
||||
} else {
|
||||
imperial = `${Math.round(feet * 100) / 100} ft`;
|
||||
}
|
||||
return { metric, imperial };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ReturnType<typeof computeSegmentMetrics>} metrics
|
||||
* @param {(key: string) => string} t i18n
|
||||
* @returns {string}
|
||||
*/
|
||||
export function buildBearingOverlayHtml(metrics, t) {
|
||||
const geo = formatLengthPairMeters(metrics.geodesicMeters);
|
||||
const rh = formatLengthPairMeters(metrics.rhumbMeters);
|
||||
const fd = metrics.forwardAzimuthDeg.toFixed(1);
|
||||
const bd = metrics.backAzimuthDeg.toFixed(1);
|
||||
const rfd = metrics.rhumbBearingDeg.toFixed(1);
|
||||
const rbd = metrics.rhumbBackBearingDeg.toFixed(1);
|
||||
return (
|
||||
`<div class="text-left space-y-0.5">` +
|
||||
`<div class="font-semibold text-gray-900 dark:text-zinc-100">${escapeHtml(t("map.bearing_geodesic"))}</div>` +
|
||||
`<div>${escapeHtml(geo.metric)} <span class="text-[10px] opacity-80">(${escapeHtml(geo.imperial)})</span></div>` +
|
||||
`<div>${escapeHtml(t("map.bearing_forward"))}: ${escapeHtml(fd)}°</div>` +
|
||||
`<div>${escapeHtml(t("map.bearing_back"))}: ${escapeHtml(bd)}°</div>` +
|
||||
`<div class="mt-1 font-semibold text-gray-900 dark:text-zinc-100">${escapeHtml(t("map.bearing_rhumb"))}</div>` +
|
||||
`<div>${escapeHtml(rh.metric)} <span class="text-[10px] opacity-80">(${escapeHtml(rh.imperial)})</span></div>` +
|
||||
`<div>${escapeHtml(t("map.bearing_rhumb_line"))}: ${escapeHtml(rfd)}° / ${escapeHtml(rbd)}°</div>` +
|
||||
`</div>`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ReturnType<typeof computeSegmentMetrics>} metrics
|
||||
* @param {(key: string) => string} t
|
||||
* @returns {string}
|
||||
*/
|
||||
export function buildBearingLiveTooltipHtml(metrics, t) {
|
||||
const geo = formatLengthPairMeters(metrics.geodesicMeters);
|
||||
const fd = metrics.forwardAzimuthDeg.toFixed(1);
|
||||
const rd = metrics.rhumbBearingDeg.toFixed(1);
|
||||
return (
|
||||
`<span class="font-semibold">${escapeHtml(t("map.bearing_geodesic"))}</span> ` +
|
||||
`${escapeHtml(geo.metric)}<br/>` +
|
||||
`<span class="text-[10px] opacity-90">${escapeHtml(t("map.bearing_forward"))}: ${escapeHtml(fd)}° · ` +
|
||||
`${escapeHtml(t("map.bearing_rhumb_line"))}: ${escapeHtml(rd)}°</span>`
|
||||
);
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
|
||||
/**
|
||||
* Offline-friendly map deep links: meshchatx://map?lat=&lon=&z=&layers=&label=
|
||||
* (meshchat://map is accepted as an alias.)
|
||||
*/
|
||||
|
||||
const MAP_URI_IN_TEXT_RE = /(?:meshchatx|meshchat):\/\/map\?[^\s<>]*/gi;
|
||||
|
||||
export function findMapUriInContent(text) {
|
||||
if (!text || typeof text !== "string") {
|
||||
return null;
|
||||
}
|
||||
const matches = text.match(MAP_URI_IN_TEXT_RE);
|
||||
return matches && matches.length ? matches[0] : null;
|
||||
}
|
||||
|
||||
export function parseMeshchatMapUri(uri) {
|
||||
if (!uri || typeof uri !== "string") {
|
||||
return null;
|
||||
}
|
||||
const s = uri.trim();
|
||||
if (!/^(meshchatx|meshchat):\/\/map\b/i.test(s)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const u = new URL(s);
|
||||
const lat = parseFloat(u.searchParams.get("lat") ?? "");
|
||||
const lon = parseFloat(u.searchParams.get("lon") ?? "");
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
|
||||
return null;
|
||||
}
|
||||
const zRaw = u.searchParams.get("z") ?? u.searchParams.get("zoom") ?? "10";
|
||||
let zoom = Math.round(parseFloat(zRaw));
|
||||
if (!Number.isFinite(zoom)) {
|
||||
zoom = 10;
|
||||
}
|
||||
zoom = Math.max(0, Math.min(22, zoom));
|
||||
const layers = (u.searchParams.get("layers") || "").trim();
|
||||
const label = (u.searchParams.get("label") || "").trim();
|
||||
return { lat, lon, zoom, layers, label, raw: s };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildMeshchatMapUri({ lat, lon, zoom, layers = "", label = "" }) {
|
||||
const z = Math.round(Number(zoom));
|
||||
const parts = [`lat=${encodeURIComponent(lat)}`, `lon=${encodeURIComponent(lon)}`, `z=${encodeURIComponent(z)}`];
|
||||
if (layers) {
|
||||
parts.push(`layers=${encodeURIComponent(layers)}`);
|
||||
}
|
||||
if (label) {
|
||||
parts.push(`label=${encodeURIComponent(label)}`);
|
||||
}
|
||||
return `meshchatx://map?${parts.join("&")}`;
|
||||
}
|
||||
|
||||
export function buildWebHashMapUrl({ lat, lon, zoom, layers = "", label = "" }) {
|
||||
const q = new URLSearchParams();
|
||||
q.set("lat", String(lat));
|
||||
q.set("lon", String(lon));
|
||||
q.set("zoom", String(Math.round(Number(zoom))));
|
||||
if (layers) {
|
||||
q.set("layers", layers);
|
||||
}
|
||||
if (label) {
|
||||
q.set("label", label);
|
||||
}
|
||||
const base = typeof window !== "undefined" ? `${window.location.origin}${window.location.pathname}` : "";
|
||||
return `${base}#/map?${q.toString()}`;
|
||||
}
|
||||
|
||||
export function mapLinkKindFromMessage(content, parsed) {
|
||||
if (content && typeof content === "string" && /MeshChatX\s+map\s+ping/i.test(content)) {
|
||||
return "ping";
|
||||
}
|
||||
if (parsed?.label && /^ping$/i.test(parsed.label)) {
|
||||
return "ping";
|
||||
}
|
||||
return "view";
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export const TILE_FETCH_TIMEOUT_MS = 22000;
|
||||
export const TILE_FETCH_RETRIES = 2;
|
||||
export const TILE_FETCH_RETRY_BASE_DELAY_MS = 450;
|
||||
|
||||
export const NOMINATIM_FETCH_TIMEOUT_MS = 16000;
|
||||
export const NOMINATIM_FETCH_RETRIES = 1;
|
||||
export const NOMINATIM_FETCH_RETRY_BASE_DELAY_MS = 500;
|
||||
|
||||
export function normalizeHttpBaseUrl(url) {
|
||||
if (!url || typeof url !== "string") return "";
|
||||
return url.endsWith("/") ? url.slice(0, -1) : url;
|
||||
}
|
||||
|
||||
export function buildNominatimSearchUrl(nominatimApiUrl, searchQuery, limit = 10) {
|
||||
const base = normalizeHttpBaseUrl(nominatimApiUrl);
|
||||
const enc = encodeURIComponent(searchQuery);
|
||||
return `${base}/search?format=json&q=${enc}&limit=${limit}&addressdetails=1`;
|
||||
}
|
||||
|
||||
export async function fetchWithTimeout(resource, init = {}, timeoutMs = TILE_FETCH_TIMEOUT_MS) {
|
||||
const controller = new AbortController();
|
||||
const id = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await fetch(resource, { ...init, signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(id);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchTileBlobWithRetry(url, init = {}, options = {}) {
|
||||
const timeoutMs = options.timeoutMs ?? TILE_FETCH_TIMEOUT_MS;
|
||||
const retries = options.retries ?? TILE_FETCH_RETRIES;
|
||||
const baseDelay = options.retryBaseDelayMs ?? TILE_FETCH_RETRY_BASE_DELAY_MS;
|
||||
let lastErr;
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
if (attempt > 0) await delay(baseDelay * attempt);
|
||||
try {
|
||||
const response = await fetchWithTimeout(url, init, timeoutMs);
|
||||
if (!response.ok) {
|
||||
return { ok: false, status: response.status, error: new Error(`HTTP ${response.status}`) };
|
||||
}
|
||||
const blob = await response.blob();
|
||||
return { ok: true, blob };
|
||||
} catch (e) {
|
||||
lastErr = e;
|
||||
}
|
||||
}
|
||||
return { ok: false, error: lastErr };
|
||||
}
|
||||
|
||||
export async function fetchJsonWithRetry(url, init = {}, options = {}) {
|
||||
const timeoutMs = options.timeoutMs ?? NOMINATIM_FETCH_TIMEOUT_MS;
|
||||
const retries = options.retries ?? NOMINATIM_FETCH_RETRIES;
|
||||
const baseDelay = options.retryBaseDelayMs ?? NOMINATIM_FETCH_RETRY_BASE_DELAY_MS;
|
||||
let lastErr;
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
if (attempt > 0) await delay(baseDelay * attempt);
|
||||
try {
|
||||
const response = await fetchWithTimeout(url, init, timeoutMs);
|
||||
if (!response.ok) {
|
||||
return { ok: false, status: response.status, error: new Error(`HTTP ${response.status}`) };
|
||||
}
|
||||
return { ok: true, response };
|
||||
} catch (e) {
|
||||
lastErr = e;
|
||||
}
|
||||
}
|
||||
return { ok: false, error: lastErr };
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import "@fontsource/noto-sans/400.css";
|
||||
import "@fontsource/noto-sans/400-italic.css";
|
||||
import "@fontsource/noto-sans/700.css";
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Builds SVG paths and layout metadata for a telemetry battery trend chart.
|
||||
* Uses a wide viewBox so preserveAspectRatio "meet" fills typical modal widths
|
||||
* without leaving a short square plot.
|
||||
*/
|
||||
|
||||
export const BATTERY_CHART_VIEWBOX = { w: 100, h: 46 };
|
||||
|
||||
export const BATTERY_CHART_BOUNDS = {
|
||||
PL: 10,
|
||||
PR: 99,
|
||||
PT: 5,
|
||||
PB: 38,
|
||||
};
|
||||
|
||||
const { PL, PR, PT, PB } = BATTERY_CHART_BOUNDS;
|
||||
const VB = BATTERY_CHART_VIEWBOX;
|
||||
|
||||
/**
|
||||
* @param {number} v
|
||||
* @returns {number}
|
||||
*/
|
||||
export function clampBatteryPercent(v) {
|
||||
const n = Number(v);
|
||||
if (Number.isNaN(n)) return 0;
|
||||
return Math.min(100, Math.max(0, n));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ x: number; y: number }[]} history ascending by x
|
||||
* @returns {{ x: number; y: number }[]}
|
||||
*/
|
||||
function plotPoints(history) {
|
||||
if (history.length === 0) return [];
|
||||
const minX = history[0].x;
|
||||
const maxX = history[history.length - 1].x;
|
||||
const rangeX = maxX - minX || 1;
|
||||
return history.map((p) => ({
|
||||
x: PL + ((p.x - minX) / rangeX) * (PR - PL),
|
||||
y: PT + (1 - clampBatteryPercent(p.y) / 100) * (PB - PT),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Smooth cubic path through points (Catmull-Rom style control points).
|
||||
* @param {{ x: number; y: number }[]} pts
|
||||
* @returns {string}
|
||||
*/
|
||||
function smoothLinePath(pts) {
|
||||
if (pts.length === 0) return "";
|
||||
if (pts.length === 1) return `M ${pts[0].x} ${pts[0].y}`;
|
||||
let d = `M ${pts[0].x} ${pts[0].y}`;
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
const p0 = i > 0 ? pts[i - 1] : pts[0];
|
||||
const p1 = pts[i];
|
||||
const p2 = pts[i + 1];
|
||||
const p3 = i + 2 < pts.length ? pts[i + 2] : p2;
|
||||
const cp1x = p1.x + (p2.x - p0.x) / 6;
|
||||
const cp1y = p1.y + (p2.y - p0.y) / 6;
|
||||
const cp2x = p2.x - (p3.x - p1.x) / 6;
|
||||
const cp2y = p2.y - (p3.y - p1.y) / 6;
|
||||
d += ` C ${cp1x} ${cp1y} ${cp2x} ${cp2y} ${p2.x} ${p2.y}`;
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ x: number; y: number }[]} history
|
||||
* @param {string} idSuffix alphanumeric fragment for SVG defs ids
|
||||
* @returns {null | {
|
||||
* linePath: string;
|
||||
* areaPath: string;
|
||||
* gridLines: { y1: number; y2: number; label: string }[];
|
||||
* first: { x: number; y: number };
|
||||
* last: { x: number; y: number };
|
||||
* plotBottom: number;
|
||||
* gradientId: string;
|
||||
* strokeGradientId: string;
|
||||
* layout: { PL: number; PR: number; PT: number; PB: number; plotBottom: number; minX: number; maxX: number };
|
||||
* viewBox: string;
|
||||
* }}
|
||||
*/
|
||||
export function buildTelemetryBatteryChartSpec(history, idSuffix = "chart") {
|
||||
const safe = String(idSuffix).replace(/[^a-zA-Z0-9_-]/g, "") || "chart";
|
||||
if (history.length < 2) return null;
|
||||
|
||||
const minX = history[0].x;
|
||||
const maxX = history[history.length - 1].x;
|
||||
|
||||
const pts = plotPoints(history);
|
||||
const linePath = smoothLinePath(pts);
|
||||
const first = pts[0];
|
||||
const last = pts[pts.length - 1];
|
||||
const plotBottom = Math.min(PB + 5, VB.h - 1);
|
||||
const areaPath = `${linePath} L ${last.x} ${plotBottom} L ${first.x} ${plotBottom} Z`;
|
||||
|
||||
const gridLines = [100, 75, 50, 25, 0].map((pct) => {
|
||||
const y = PT + (1 - pct / 100) * (PB - PT);
|
||||
return { y1: y, y2: y, label: `${pct}` };
|
||||
});
|
||||
|
||||
return {
|
||||
linePath,
|
||||
areaPath,
|
||||
gridLines,
|
||||
first,
|
||||
last,
|
||||
plotBottom,
|
||||
gradientId: `tb-fill-${safe}`,
|
||||
strokeGradientId: `tb-stroke-${safe}`,
|
||||
layout: { PL, PR, PT, PB, plotBottom, minX, maxX },
|
||||
viewBox: `0 0 ${VB.w} ${VB.h}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Linear interpolation of charge_percent between samples (by timestamp).
|
||||
* @param {{ x: number; y: number }[]} history ascending by x
|
||||
* @param {number} ts unix seconds
|
||||
* @returns {{ y: number; x: number }}
|
||||
*/
|
||||
export function interpolateBatteryByTime(history, ts) {
|
||||
if (!history.length) return { y: 0, x: ts };
|
||||
if (history.length === 1) return { y: clampBatteryPercent(history[0].y), x: history[0].x };
|
||||
if (ts <= history[0].x) return { y: clampBatteryPercent(history[0].y), x: history[0].x };
|
||||
const hiLast = history[history.length - 1];
|
||||
if (ts >= hiLast.x) return { y: clampBatteryPercent(hiLast.y), x: hiLast.x };
|
||||
|
||||
let lo = 0;
|
||||
let hi = history.length - 1;
|
||||
while (hi - lo > 1) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (history[mid].x <= ts) lo = mid;
|
||||
else hi = mid;
|
||||
}
|
||||
const a = history[lo];
|
||||
const b = history[hi];
|
||||
const span = b.x - a.x || 1;
|
||||
const u = (ts - a.x) / span;
|
||||
return { y: clampBatteryPercent(a.y + u * (b.y - a.y)), x: ts };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object[]} telemetryChatItems lxmf chat items (telemetry-only ok)
|
||||
* @returns {{ x: number; y: number }[]}
|
||||
*/
|
||||
export function batteryHistoryFromTelemetryItems(telemetryChatItems) {
|
||||
return telemetryChatItems
|
||||
.filter((item) => item.lxmf_message?.fields?.telemetry?.battery)
|
||||
.map((item) => ({
|
||||
x: item.lxmf_message.timestamp,
|
||||
y: item.lxmf_message.fields.telemetry.battery.charge_percent,
|
||||
}))
|
||||
.sort((a, b) => a.x - b.x);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
|
||||
class TelephonePcmCaptureProcessor extends AudioWorkletProcessor {
|
||||
process(inputs) {
|
||||
const input = inputs[0];
|
||||
if (!input || input.length === 0) {
|
||||
return true;
|
||||
}
|
||||
const ch0 = input[0];
|
||||
if (!ch0 || ch0.length === 0) {
|
||||
return true;
|
||||
}
|
||||
const pcm = new Int16Array(ch0.length);
|
||||
for (let i = 0; i < ch0.length; i += 1) {
|
||||
const s = ch0[i];
|
||||
pcm[i] = Math.max(-1, Math.min(1, s)) * 0x7fff;
|
||||
}
|
||||
this.port.postMessage(pcm.buffer, [pcm.buffer]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor("telephone-pcm-capture", TelephonePcmCaptureProcessor);
|
||||
Reference in New Issue
Block a user