diff --git a/assets/js/app.js b/assets/js/app.ts similarity index 78% rename from assets/js/app.js rename to assets/js/app.ts index bf822188..8f67c9e3 100644 --- a/assets/js/app.js +++ b/assets/js/app.ts @@ -1,22 +1,3 @@ -// If you want to use Phoenix channels, run `mix help phx.gen.channel` -// to get started and then uncomment the line below. -// import "./user_socket.js" - -// You can include dependencies in two ways. -// -// The simplest option is to put them in assets/vendor and -// import them using relative paths: -// -// import "../vendor/some-package.js" -// -// Alternatively, you can `npm install some-package --prefix assets` and import -// them using a path starting with the package name: -// -// import "some-package" -// -// If you have dependencies that try to import CSS, esbuild will generate a separate `app.css` file. -// To load it, simply add a second `` to your `root.html.heex` file. - // Leaflet map library (vendored) import * as L from "../vendor/leaflet/leaflet.js" window.L = L @@ -39,33 +20,42 @@ import {LocateMe, CopyLink} from "./locate_me_hook" import {RoverMap} from "./rover_map_hook" import {BeaconMap} from "./beacon_map_hook" +interface UtcClockHook extends ViewHook { + timer: ReturnType + tick(): void +} + const UtcClock = { - mounted() { + mounted(this: UtcClockHook) { this.tick() this.timer = setInterval(() => this.tick(), 10_000) }, - tick() { + tick(this: UtcClockHook) { const now = new Date() const h = now.getUTCHours().toString().padStart(2, "0") const m = now.getUTCMinutes().toString().padStart(2, "0") this.el.textContent = `${h}:${m} UTC` }, - destroyed() { + destroyed(this: UtcClockHook) { clearInterval(this.timer) } } +interface CommaNumberHook extends ViewHook { + format(): void +} + const CommaNumber = { - mounted() { + mounted(this: CommaNumberHook) { this.el.addEventListener("input", () => this.format()) this.format() }, - updated() { this.format() }, - format() { - const input = this.el.querySelector("input") || this.el + updated(this: CommaNumberHook) { this.format() }, + format(this: CommaNumberHook) { + const input = (this.el.querySelector("input") || this.el) as HTMLInputElement const raw = input.value.replace(/,/g, "") if (raw === "" || raw === "-") return - const cursor = input.selectionStart + const cursor = input.selectionStart ?? 0 const commasBefore = (input.value.slice(0, cursor).match(/,/g) || []).length const parts = raw.split(".") parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",") @@ -79,7 +69,7 @@ const CommaNumber = { } } -const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") +const csrfToken = document.querySelector("meta[name='csrf-token']")!.getAttribute("content") const liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 2500, params: initLiveStash({_csrf_token: csrfToken}), @@ -107,7 +97,7 @@ window.liveSocket = liveSocket // 2. click on elements to jump to their definitions in your code editor // if (process.env.NODE_ENV === "development") { - window.addEventListener("phx:live_reload:attached", ({detail: reloader}) => { + window.addEventListener("phx:live_reload:attached", ({detail: reloader}: any) => { // Enable server log streaming to client. // Disable with reloader.disableServerLogs() reloader.enableServerLogs() @@ -116,10 +106,10 @@ if (process.env.NODE_ENV === "development") { // // * click with "c" key pressed to open at caller location // * click with "d" key pressed to open at function component definition location - let keyDown - window.addEventListener("keydown", e => keyDown = e.key) - window.addEventListener("keyup", _e => keyDown = null) - window.addEventListener("click", e => { + let keyDown: string | null + window.addEventListener("keydown", (e: KeyboardEvent) => keyDown = e.key) + window.addEventListener("keyup", (_e: KeyboardEvent) => keyDown = null) + window.addEventListener("click", (e: MouseEvent) => { if(keyDown === "c"){ e.preventDefault() e.stopImmediatePropagation() @@ -134,4 +124,3 @@ if (process.env.NODE_ENV === "development") { window.liveReloader = reloader }) } - diff --git a/assets/js/beacon_map_hook.js b/assets/js/beacon_map_hook.ts similarity index 63% rename from assets/js/beacon_map_hook.js rename to assets/js/beacon_map_hook.ts index a118ebfa..348ff5e8 100644 --- a/assets/js/beacon_map_hook.js +++ b/assets/js/beacon_map_hook.ts @@ -1,12 +1,31 @@ +interface CoverageCell { + lat: number + lon: number + color: string + label: string + rx_dbm: number + distance_km: number + score: number +} + +interface ToggleCoveragePayload { + show: boolean +} + +interface BeaconMapHook extends ViewHook { + mounted(this: BeaconMapHook): void +} + export const BeaconMap = { - mounted() { - const lat = parseFloat(this.el.dataset.lat) - const lon = parseFloat(this.el.dataset.lon) - const label = this.el.dataset.label || "" - const onTheAir = this.el.dataset.onTheAir === "true" - const cells = JSON.parse(this.el.dataset.cells || "[]") - const step = parseFloat(this.el.dataset.gridStep || "0.125") - const initialShowCoverage = this.el.dataset.showCoverage === "true" + mounted(this: BeaconMapHook) { + const dataset = this.el.dataset + const lat = parseFloat(dataset.lat!) + const lon = parseFloat(dataset.lon!) + const label = dataset.label || "" + const onTheAir = dataset.onTheAir === "true" + const cells: CoverageCell[] = JSON.parse(dataset.cells || "[]") + const step = parseFloat(dataset.gridStep || "0.125") + const initialShowCoverage = dataset.showCoverage === "true" const map = L.map(this.el, { zoomControl: true, @@ -22,11 +41,11 @@ export const BeaconMap = { // the user toggles it on. Using a single layerGroup keeps pan/zoom fast. const cellLayer = L.layerGroup() const half = step / 2.0 - const allBounds = [] + const allBounds: [number, number][] = [] for (const c of cells) { - const sw = [c.lat - half, c.lon - half] - const ne = [c.lat + half, c.lon + half] + const sw: [number, number] = [c.lat - half, c.lon - half] + const ne: [number, number] = [c.lat + half, c.lon + half] const rect = L.rectangle([sw, ne], { color: c.color, weight: 0, @@ -57,7 +76,7 @@ export const BeaconMap = { offset: [0, -10] }) - const showLayer = () => { + const showLayer = (): void => { if (!map.hasLayer(cellLayer)) cellLayer.addTo(map) if (allBounds.length > 0) { map.fitBounds(L.latLngBounds(allBounds), {padding: [20, 20]}) @@ -65,14 +84,14 @@ export const BeaconMap = { } } - const hideLayer = () => { + const hideLayer = (): void => { if (map.hasLayer(cellLayer)) map.removeLayer(cellLayer) map.setView([lat, lon], 11) } if (initialShowCoverage) showLayer() - this.handleEvent("toggle_coverage", ({show}) => { + this.handleEvent("toggle_coverage", ({show}: ToggleCoveragePayload) => { if (show) { showLayer() } else { diff --git a/assets/js/contact_map_hook.js b/assets/js/contact_map_hook.ts similarity index 82% rename from assets/js/contact_map_hook.js rename to assets/js/contact_map_hook.ts index 63f6c6db..31809704 100644 --- a/assets/js/contact_map_hook.js +++ b/assets/js/contact_map_hook.ts @@ -1,19 +1,25 @@ import { updateGridOverlay } from "./maidenhead_grid" +interface ContactMapHook extends ViewHook { + gridLayer: L.LayerGroup + gridVisible: boolean + initMap(): void +} + export const ContactMap = { - mounted() { + mounted(this: ContactMapHook) { // Defer init until container has dimensions (flex layout timing) requestAnimationFrame(() => this.initMap()) }, - initMap() { - const pos1 = JSON.parse(this.el.dataset.pos1) - const pos2 = JSON.parse(this.el.dataset.pos2) + initMap(this: ContactMapHook) { + const pos1 = JSON.parse(this.el.dataset.pos1!) as { lat: number; lon?: number; lng?: number } + const pos2 = JSON.parse(this.el.dataset.pos2!) as { lat: number; lon?: number; lng?: number } const lat1 = pos1.lat - const lon1 = pos1.lon || pos1.lng + const lon1 = (pos1.lon || pos1.lng)! const lat2 = pos2.lat - const lon2 = pos2.lon || pos2.lng + const lon2 = (pos2.lon || pos2.lng)! const bounds = L.latLngBounds([[lat1, lon1], [lat2, lon2]]) @@ -37,7 +43,7 @@ export const ContactMap = { // Grid toggle control const GridToggle = L.Control.extend({ - options: { position: "topright" }, + options: { position: "topright" as const }, onAdd: () => { const btn = L.DomUtil.create("button", "leaflet-bar leaflet-control") btn.innerHTML = "Grid" diff --git a/assets/js/contacts_map_hook.js b/assets/js/contacts_map_hook.ts similarity index 75% rename from assets/js/contacts_map_hook.js rename to assets/js/contacts_map_hook.ts index 67dd386b..efeec48d 100644 --- a/assets/js/contacts_map_hook.js +++ b/assets/js/contacts_map_hook.ts @@ -1,5 +1,28 @@ +// Contact tuple indices: [lat1, lon1, lat2, lon2, band, s1, s2, mode, dist, ts, id] +type ContactTuple = [ + number, number, number, number, number, + string | null, string | null, string | null, + number | null, string | null, string +] + +interface ApplyFilterPayload { + enabled_bands: number[] + callsign: string | null +} + +interface ContactsMapHook extends ViewHook { + allContacts: ContactTuple[] + callsignFilter: string + enabledBands: Set + lineLayer: L.LayerGroup + dotLayer: L.LayerGroup + map: L.Map + initMap(this: ContactsMapHook): void + rebuildMap(this: ContactsMapHook): void +} + // Band MHz -> color mapping -const BAND_COLORS = { +const BAND_COLORS: Record = { 1296: "#475569", // slate-600 2304: "#7c3aed", // violet-600 3456: "#4f46e5", // indigo-600 @@ -14,7 +37,7 @@ const BAND_COLORS = { 241000: "#b91c1c", // red-700 } -function bandLabel(mhz) { +function bandLabel(mhz: number): string { if (mhz >= 1000) { const ghz = mhz / 1000 return (Number.isInteger(ghz) ? ghz : ghz.toFixed(1)) + " GHz" @@ -22,20 +45,20 @@ function bandLabel(mhz) { return mhz + " MHz" } -function bandColor(band) { +function bandColor(band: number): string { return BAND_COLORS[band] || "#64748b" } export const ContactsMap = { - mounted() { - this.allContacts = JSON.parse(this.el.dataset.contacts) + mounted(this: ContactsMapHook) { + this.allContacts = JSON.parse(this.el.dataset.contacts!) this.callsignFilter = "" this.enabledBands = new Set() this.lineLayer = L.layerGroup() this.dotLayer = L.layerGroup() // Listen for filter changes from LiveView - this.handleEvent("apply_filter", ({enabled_bands, callsign}) => { + this.handleEvent("apply_filter", ({enabled_bands, callsign}: ApplyFilterPayload) => { this.enabledBands = new Set(enabled_bands) this.callsignFilter = (callsign || "").toUpperCase() this.rebuildMap() @@ -45,7 +68,7 @@ export const ContactsMap = { requestAnimationFrame(() => this.initMap()) }, - initMap() { + initMap(this: ContactsMapHook) { this.map = L.map(this.el, { center: [37.5, -96], zoom: 5, @@ -68,7 +91,7 @@ export const ContactsMap = { this.rebuildMap() }, - rebuildMap() { + rebuildMap(this: ContactsMapHook) { this.lineLayer.clearLayers() this.dotLayer.clearLayers() @@ -91,7 +114,7 @@ export const ContactsMap = { if (countMobile) countMobile.textContent = `${filtered.length.toLocaleString()} contacts` // Draw lines - const endpointCounts = {} + const endpointCounts: Record = {} for (const c of filtered) { const [lat1, lon1, lat2, lon2, band, s1, s2, mode, dist, ts, id] = c const color = bandColor(band) @@ -111,8 +134,8 @@ export const ContactsMap = { {closeButton: false, offset: [0, -4]} ) - line.on("mouseover", function() { this.setStyle({weight: 4, opacity: 1}) }) - line.on("mouseout", function() { this.setStyle({weight: 2, opacity: 0.5}) }) + line.on("mouseover", function(this: L.Polyline) { this.setStyle({weight: 4, opacity: 1}) }) + line.on("mouseout", function(this: L.Polyline) { this.setStyle({weight: 2, opacity: 0.5}) }) this.lineLayer.addLayer(line) diff --git a/assets/js/elevation_profile_hook.js b/assets/js/elevation_profile_hook.ts similarity index 74% rename from assets/js/elevation_profile_hook.js rename to assets/js/elevation_profile_hook.ts index 12643d0f..0a289f20 100644 --- a/assets/js/elevation_profile_hook.js +++ b/assets/js/elevation_profile_hook.ts @@ -3,10 +3,57 @@ import Chart from "../vendor/chart.js" const M2FT = 3.28084 const KM2MI = 0.621371 +interface ProfilePoint { + dist_km: number + elev: number + beam: number + r1: number +} + +interface DuctRaw { + base_m_msl: number + top_m_msl: number + strength: number + source?: DuctSource + likely?: boolean +} + +type DuctSource = "hrrr" | "sounding" | "inferred" + +interface DuctBand { + base_ft: number + top_ft: number + strength: number + source: DuctSource + likely: boolean +} + +interface ProfileData { + points: ProfilePoint[] + freq_mhz: number + k_factor?: number + dist_km?: number + ducts?: DuctRaw[] +} + +interface ChartWithAreas { + options: { + plugins: { ductBands?: { ducts: DuctBand[] } } + } + ctx: CanvasRenderingContext2D + chartArea: { left: number; right: number; top: number; bottom: number } + scales: { y: { getPixelForValue(value: number): number } } +} + +interface ElevationProfileHook extends ViewHook { + chart: Chart | null + renderChart(this: ElevationProfileHook, data: ProfileData): void +} + // Chart.js plugin to draw horizontal duct bands const ductPlugin = { id: "ductBands", - beforeDraw(chart) { + beforeDraw(chart: ChartWithAreas): void { const ducts = chart.options.plugins.ductBands?.ducts if (!ducts || ducts.length === 0) return @@ -46,8 +93,8 @@ const ductPlugin = { const textAlpha = likely ? 1.0 : 0.5 ctx.fillStyle = `rgba(34, 197, 94, ${textAlpha})` ctx.font = likely ? "bold 10px sans-serif" : "9px sans-serif" - const labels = {hrrr: "Duct", sounding: "Duct (sounding)", inferred: "Est. duct"} - const units = {hrrr: "M-units", sounding: "M-units", inferred: "dN/dh÷10"} + const labels: Record = {hrrr: "Duct", sounding: "Duct (sounding)", inferred: "Est. duct"} + const units: Record = {hrrr: "M-units", sounding: "M-units", inferred: "dN/dh÷10"} const prefix = labels[duct.source] || "Duct" const unit = units[duct.source] || "M-units" const tag = likely ? " — likely path" : "" @@ -57,14 +104,14 @@ const ductPlugin = { } } -export const ElevationProfile = { - mounted() { - const data = JSON.parse(this.el.dataset.profile) +export const ElevationProfile: ElevationProfileHook = { + mounted(this: ElevationProfileHook) { + const data: ProfileData = JSON.parse(this.el.dataset.profile!) if (!data || !data.points || data.points.length === 0) return this.renderChart(data) }, - renderChart(data) { + renderChart(this: ElevationProfileHook, data: ProfileData) { const points = data.points const distances = points.map(p => p.dist_km * KM2MI) const showFresnel = data.freq_mhz >= 1000 @@ -82,7 +129,7 @@ export const ElevationProfile = { const losLine = points.map(p => p.beam * M2FT) const fresnelLower = points.map(p => (p.beam - p.r1) * M2FT) - const ducts = (data.ducts || []).map(d => ({ + const ducts: DuctBand[] = (data.ducts || []).map(d => ({ base_ft: d.base_m_msl * M2FT, top_ft: d.top_m_msl * M2FT, strength: d.strength, @@ -99,9 +146,9 @@ export const ElevationProfile = { if (d.top_ft + 40 > maxElev) maxElev = d.top_ft + 40 } - const canvas = this.el.querySelector("canvas") + const canvas = this.el.querySelector("canvas") as HTMLCanvasElement | null if (!canvas) return - const ctx = canvas.getContext("2d") + const ctx = canvas.getContext("2d")! this.chart = new Chart(ctx, { type: "line", @@ -154,7 +201,7 @@ export const ElevationProfile = { // Invisible dataset for duct legend entry ...(ducts.length > 0 ? [{ label: "Duct Layer", - data: [], + data: [] as number[], borderColor: "rgba(34, 197, 94, 0.5)", backgroundColor: "rgba(34, 197, 94, 0.12)", borderWidth: 2, @@ -176,13 +223,13 @@ export const ElevationProfile = { labels: { boxWidth: 12, font: {size: 10}, - filter: item => !item.text.startsWith("_") + filter: (item: { text: string }) => !item.text.startsWith("_") } }, tooltip: { callbacks: { - title: items => items[0].label + " mi", - label: item => { + title: (items: { label: string }[]) => items[0].label + " mi", + label: (item: { dataset: { label: string }; raw: number }) => { if (item.dataset.label.startsWith("_")) return null return item.dataset.label + ": " + Math.round(item.raw) + " ft" } @@ -192,11 +239,11 @@ export const ElevationProfile = { scales: { x: { title: {display: true, text: "Distance (mi)", font: {size: 10}}, - afterBuildTicks: axis => { + afterBuildTicks: (axis: { ticks: { value: number }[] }) => { const last = distances.length - 1 const totalMi = distances[last] const step = Math.ceil(totalMi / 7) || 1 - const ticks = [] + const ticks: { value: number }[] = [] for (let mi = 0; mi < totalMi; mi += step) { const idx = distances.findIndex(d => d >= mi) if (idx >= 0) ticks.push({value: idx}) @@ -207,7 +254,7 @@ export const ElevationProfile = { ticks: { font: {size: 9}, autoSkip: false, - callback: val => distances[val] !== undefined ? Math.round(distances[val]) : "" + callback: (val: number) => distances[val] !== undefined ? Math.round(distances[val]) : "" } }, y: { @@ -221,10 +268,10 @@ export const ElevationProfile = { }) }, - destroyed() { + destroyed(this: ElevationProfileHook) { if (this.chart) { this.chart.destroy() this.chart = null } } -} +} as ElevationProfileHook diff --git a/assets/js/locate_me_hook.js b/assets/js/locate_me_hook.ts similarity index 77% rename from assets/js/locate_me_hook.js rename to assets/js/locate_me_hook.ts index 09d57374..a656fd16 100644 --- a/assets/js/locate_me_hook.js +++ b/assets/js/locate_me_hook.ts @@ -1,5 +1,9 @@ +interface CopyLinkHook extends ViewHook { + el: HTMLElement +} + export const CopyLink = { - mounted() { + mounted(this: CopyLinkHook): void { this.el.addEventListener("click", () => { navigator.clipboard.writeText(window.location.href).then(() => { const orig = this.el.innerHTML @@ -10,8 +14,15 @@ export const CopyLink = { } } +interface LocateMeHook extends ViewHook { + el: HTMLElement + watchId: number | null + lastSentAt: number + startWatching(): void +} + export const LocateMe = { - mounted() { + mounted(this: LocateMeHook): void { this.watchId = null this.lastSentAt = 0 @@ -21,13 +32,13 @@ export const LocateMe = { this.handleEvent("request_gps", () => this.startWatching()) }, - destroyed() { + destroyed(this: LocateMeHook): void { if (this.watchId !== null) { navigator.geolocation.clearWatch(this.watchId) } }, - startWatching() { + startWatching(this: LocateMeHook): void { if (!navigator.geolocation) { alert("Geolocation is not supported by this browser") return @@ -39,7 +50,7 @@ export const LocateMe = { this.el.classList.add("loading", "loading-spinner") this.watchId = navigator.geolocation.watchPosition( - (pos) => { + (pos: GeolocationPosition) => { const now = Date.now() // Send immediately on first fix, then at most once per minute if (this.lastSentAt === 0 || now - this.lastSentAt >= 60000) { @@ -51,9 +62,9 @@ export const LocateMe = { }) } }, - (err) => { + (err: GeolocationPositionError) => { this.el.classList.remove("loading", "loading-spinner") - navigator.geolocation.clearWatch(this.watchId) + navigator.geolocation.clearWatch(this.watchId!) this.watchId = null alert("Could not get location: " + err.message) }, diff --git a/assets/js/maidenhead_grid.js b/assets/js/maidenhead_grid.ts similarity index 87% rename from assets/js/maidenhead_grid.js rename to assets/js/maidenhead_grid.ts index 7706a4b2..cb290e00 100644 --- a/assets/js/maidenhead_grid.js +++ b/assets/js/maidenhead_grid.ts @@ -1,13 +1,24 @@ // Maidenhead grid square overlay shared between the main propagation map // and the rover planner map. Draws red field/square rectangles with labels. +interface GridBounds { + west: number + east: number + south: number + north: number + center: { lat: number; lon: number } +} + export class GridSquare { - constructor(lat, lon) { + lat: number + lon: number + + constructor(lat: number, lon: number) { this.lat = lat this.lon = ((lon % 360) + 540) % 360 - 180 } - encode(precision = 4) { + encode(precision = 4): string { const adjLon = this.lon + 180 const adjLat = this.lat + 90 let g = "" @@ -22,7 +33,7 @@ export class GridSquare { return g } - static decode(grid) { + static decode(grid: string): GridBounds { const f1 = grid.charCodeAt(0) - 65 const f2 = grid.charCodeAt(1) - 65 let west = f1 * 20 - 180 @@ -46,7 +57,14 @@ export class GridSquare { } } -function drawGridSquare(grid, precision, bounds, layerGroup, drawnSet, map) { +function drawGridSquare( + grid: string, + precision: number, + bounds: L.LatLngBounds, + layerGroup: L.LayerGroup, + drawnSet: Set, + map: L.Map +): void { if (grid.length < 2 || drawnSet.has(grid)) return drawnSet.add(grid) @@ -81,9 +99,9 @@ function drawGridSquare(grid, precision, bounds, layerGroup, drawnSet, map) { } } -export function updateGridOverlay(map, gridLayer) { +export function updateGridOverlay(map: L.Map, gridLayer: L.LayerGroup): void { gridLayer.clearLayers() - const drawn = new Set() + const drawn = new Set() const bounds = map.getBounds() const zoom = map.getZoom() const showSquares = zoom >= 7 diff --git a/assets/js/propagation_map_hook.js b/assets/js/propagation_map_hook.ts similarity index 79% rename from assets/js/propagation_map_hook.js rename to assets/js/propagation_map_hook.ts index c5fbe7bc..d4cf7a2f 100644 --- a/assets/js/propagation_map_hook.js +++ b/assets/js/propagation_map_hook.ts @@ -1,9 +1,163 @@ import topbar from "../vendor/topbar" import { updateGridOverlay } from "./maidenhead_grid" +// --- Interfaces --- + +interface FactorMeta { + label: string + weight: number + unit: string +} + +interface ScoreTier { + label: string + color: string +} + +interface ColorScaleEntry { + min: number + r: number + g: number + b: number +} + +interface RGB { + r: number + g: number + b: number +} + +interface LatLon { + lat: number + lon: number +} + +interface ScorePoint { + lat: number + lon: number + score: number + [key: string]: unknown +} + +interface ForecastPoint { + score: number + time: string +} + +interface SvgPoint { + x: number + y: number + score: number + time: Date +} + +interface ScatterCell { + lat: number + lon: number + dbz: number + bearing: number + distance_km: number + scatter_db: number +} + +interface RainScatter { + classification: string + cells: ScatterCell[] +} + +interface DuctLayer { + base_m: number + top_m: number + thickness_m: number + min_freq_ghz: number | null +} + +interface DuctInfo { + duct_count: number + ducts: DuctLayer[] +} + +interface PointDetail { + score: number + lat: number + lon: number + valid_time: string + band_label: string + humidity_effect: string + typical_range_km: number + extended_range_km: number + exceptional_range_km: number + factors: Record & { duct_info?: DuctInfo } + rain_scatter?: RainScatter + forecast?: ForecastPoint[] + [key: string]: unknown +} + +interface BandInfo { + band_label?: string + humidity_effect?: string + typical_range_km?: number + extended_range_km?: number + exceptional_range_km?: number + [key: string]: unknown +} + +interface TimelineEntry { + time: string + [key: string]: unknown +} + +interface TimelineItem extends TimelineEntry { + dt: Date + isSelected: boolean + isPast: boolean + offsetH: number + idx: number + label: string +} + +interface ViewshedResult { + origin: LatLon + points: LatLon[] +} + +/** Hook state that extends Phoenix LiveView's ViewHook */ +interface PropagationMapHook extends ViewHook { + map: L.Map + scoreOverlay: L.GridLayer | null + scores: ScorePoint[] + colorScale: ColorScaleEntry[] + gridLookup: Map + gridData: Map + bandInfo: BandInfo + rangeCircles: L.LayerGroup + gridLayer: L.LayerGroup + gridVisible: boolean + detailPanel: HTMLElement | null + viewshedLoading: boolean + lastDetail: PointDetail | null + clickedLatLng: [number, number] | null + scatterMarkers: L.LayerGroup | null + reachPolygon: L.Polygon | null + timelineEl: HTMLElement | null + timelineData: TimelineEntry[] + selectedTime: string | null + + showDetailPanel(this: PropagationMapHook): void + hideDetailPanel(this: PropagationMapHook): void + renderScores(this: PropagationMapHook, scores: ScorePoint[]): void + interpolateScore(this: PropagationMapHook, lat: number, lon: number, step: number): number | null + scoreColorRGB(this: PropagationMapHook, score: number): RGB + lookupPoint(this: PropagationMapHook, lat: number, lon: number): (ScorePoint & BandInfo) | null + renderTimeline(this: PropagationMapHook): void + sendBounds(this: PropagationMapHook): void +} + +// --- Constants --- + // Weights must match BandConfig.weights() in band_config.ex // Recalibrated 2026-04-11 via gradient descent (loss 0.42 → 0.12) -const FACTOR_META = { +const FACTOR_META: Record = { rain: { label: "Rain", weight: 0.1362, unit: "" }, humidity: { label: "Humidity", weight: 0.1243, unit: "g/m\u00b3" }, pwat: { label: "PWAT", weight: 0.1128, unit: "mm" }, @@ -16,12 +170,14 @@ const FACTOR_META = { time_of_day: { label: "Time of Day", weight: 0.0496, unit: "" } } -const FACTOR_ORDER = [ +const FACTOR_ORDER: string[] = [ "rain", "humidity", "pwat", "season", "refractivity", "pressure", "td_depression", "sky", "wind", "time_of_day" ] -function scoreTier(score) { +// --- Helper functions --- + +function scoreTier(score: number): ScoreTier { if (score >= 80) return { label: "EXCELLENT", color: "#00ffa3" } if (score >= 65) return { label: "GOOD", color: "#7dffd4" } if (score >= 50) return { label: "MARGINAL", color: "#ffe566" } @@ -29,16 +185,15 @@ function scoreTier(score) { return { label: "NEGLIGIBLE", color: "#ff4f4f" } } -function factorBar(score) { +function factorBar(score: number): string { const filled = Math.round(score / 5) const empty = 20 - filled const tier = scoreTier(score) return `${"\u2588".repeat(filled)}${"\u2591".repeat(empty)}` } -function factorExplanation(key, score, detail) { +function factorExplanation(key: string, score: number, detail: PointDetail): string { const beneficial = detail.humidity_effect === "beneficial" - const band = detail.band_label || "" switch (key) { case "humidity": @@ -112,7 +267,7 @@ function factorExplanation(key, score, detail) { } } -function rangeEstimate(score, detail) { +function rangeEstimate(score: number, detail: PointDetail): string { if (score >= 80) return `${detail.extended_range_km}\u2013${detail.exceptional_range_km}+ km` if (score >= 65) return `${detail.typical_range_km}\u2013${detail.extended_range_km} km` if (score >= 50) return `${Math.round(detail.typical_range_km * 0.7)}\u2013${detail.typical_range_km} km` @@ -125,10 +280,10 @@ function rangeEstimate(score, detail) { * with score >= minScore. Returns array of {lat, lon} boundary points * as a convex hull for drawing a polygon. */ -function propagationReach(gridLookup, startLat, startLon, minScore) { +function propagationReach(gridLookup: Map, startLat: number, startLon: number, minScore: number): LatLon[] { const step = 0.125 const maxKm = 300 - const snap = (v) => (Math.round(v / step) * step).toFixed(3) + const snap = (v: number): string => (Math.round(v / step) * step).toFixed(3) const startKey = `${snap(startLat)},${snap(startLon)}` const startScore = gridLookup.get(startKey) @@ -138,14 +293,14 @@ function propagationReach(gridLookup, startLat, startLon, minScore) { const kmPerDegLat = 111.0 const kmPerDegLon = 111.0 * cosLat - const visited = new Set() - const reachable = [] - const queue = [startKey] + const visited = new Set() + const reachable: LatLon[] = [] + const queue: string[] = [startKey] visited.add(startKey) // BFS flood fill through grid, capped at maxKm from origin while (queue.length > 0) { - const key = queue.shift() + const key = queue.shift()! const [latStr, lonStr] = key.split(",") const lat = parseFloat(latStr) const lon = parseFloat(lonStr) @@ -157,7 +312,7 @@ function propagationReach(gridLookup, startLat, startLon, minScore) { reachable.push({ lat, lon }) // Check 4 neighbors - const neighbors = [ + const neighbors: [number, number][] = [ [lat + step, lon], [lat - step, lon], [lat, lon + step], @@ -180,19 +335,19 @@ function propagationReach(gridLookup, startLat, startLon, minScore) { return convexHull(reachable) } -function convexHull(points) { +function convexHull(points: LatLon[]): LatLon[] { // Graham scan points.sort((a, b) => a.lon - b.lon || a.lat - b.lat) - const cross = (o, a, b) => + const cross = (o: LatLon, a: LatLon, b: LatLon): number => (a.lon - o.lon) * (b.lat - o.lat) - (a.lat - o.lat) * (b.lon - o.lon) - const lower = [] + const lower: LatLon[] = [] for (const p of points) { while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) lower.pop() lower.push(p) } - const upper = [] + const upper: LatLon[] = [] for (let i = points.length - 1; i >= 0; i--) { const p = points[i] while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) @@ -204,16 +359,16 @@ function convexHull(points) { return lower.concat(upper) } -function isMobile() { +function isMobile(): boolean { return window.innerWidth < 768 } -function mobileHandleHTML() { +function mobileHandleHTML(): string { if (!isMobile()) return "" return `
` } -function buildLoadingHTML(detail) { +function buildLoadingHTML(detail: PointDetail): string { const tier = scoreTier(detail.score) return `
${mobileHandleHTML()} @@ -230,7 +385,7 @@ function buildLoadingHTML(detail) {
` } -function buildForecastSvg(forecast) { +function buildForecastSvg(forecast: ForecastPoint[]): string { if (!forecast || forecast.length < 2) return "" const marginL = 28, marginR = 6, marginT = 4, marginB = 16 @@ -240,7 +395,7 @@ function buildForecastSvg(forecast) { const n = forecast.length const now = new Date() - const points = forecast.map((f, i) => { + const points: SvgPoint[] = forecast.map((f, i) => { const x = marginL + (i / (n - 1)) * plotW const y = marginT + plotH * (1 - f.score / 100) return { x, y, score: f.score, time: new Date(f.time) } @@ -250,7 +405,7 @@ function buildForecastSvg(forecast) { // "Now" dot const nowIdx = points.reduce((best, p, i) => - Math.abs(p.time - now) < Math.abs(points[best].time - now) ? i : best, 0) + Math.abs(p.time.getTime() - now.getTime()) < Math.abs(points[best].time.getTime() - now.getTime()) ? i : best, 0) const nowPt = points[nowIdx] // Trend (from now onward) @@ -302,7 +457,7 @@ function buildForecastSvg(forecast) { ` } -function buildPopupHTML(detail, viewshedLoading) { +function buildPopupHTML(detail: PointDetail, viewshedLoading: boolean): string { const tier = scoreTier(detail.score) const range = rangeEstimate(detail.score, detail) const time = new Date(detail.valid_time).toISOString().replace("T", " ").slice(0, 16) + " UTC" @@ -311,7 +466,7 @@ function buildPopupHTML(detail, viewshedLoading) { let explanations = "" for (const key of FACTOR_ORDER) { const meta = FACTOR_META[key] - const value = detail.factors[key] + const value = detail.factors[key] as number | undefined if (value === undefined) continue const pct = Math.round(meta.weight * 100) rows += ` @@ -364,10 +519,10 @@ function buildPopupHTML(detail, viewshedLoading) { ` } -function buildScatterHTML(scatter) { +function buildScatterHTML(scatter: RainScatter): string { const cls = scatter.classification const cells = scatter.cells - const clsColors = { excellent: "#059669", good: "#0d9488", marginal: "#ca8a04", none: "#666" } + const clsColors: Record = { excellent: "#059669", good: "#0d9488", marginal: "#ca8a04", none: "#666" } const clsColor = clsColors[cls] || "#666" const clsLabel = cls.charAt(0).toUpperCase() + cls.slice(1) @@ -390,13 +545,14 @@ function buildScatterHTML(scatter) { ` } -function bearingLabel(deg) { +function bearingLabel(deg: number): string { const dirs = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"] return dirs[Math.round(deg / 22.5) % 16] } -function buildDuctInfoHTML(info) { +function buildDuctInfoHTML(info: DuctInfo): string { const layers = info.ducts || [] + let layerRows = "" if (layers.length > 0) { layerRows = layers.map((d, i) => { @@ -417,13 +573,24 @@ function buildDuctInfoHTML(info) { // --- Hook --- -export const PropagationMap = { - mounted() { - const centerLat = parseFloat(this.el.dataset.centerLat) - const centerLon = parseFloat(this.el.dataset.centerLon) - const zoom = parseInt(this.el.dataset.zoom, 10) +export const PropagationMap: Record & { + mounted(this: PropagationMapHook): void + showDetailPanel(this: PropagationMapHook): void + hideDetailPanel(this: PropagationMapHook): void + renderScores(this: PropagationMapHook, scores: ScorePoint[]): void + interpolateScore(this: PropagationMapHook, lat: number, lon: number, step: number): number | null + scoreColorRGB(this: PropagationMapHook, score: number): RGB + lookupPoint(this: PropagationMapHook, lat: number, lon: number): (ScorePoint & BandInfo) | null + renderTimeline(this: PropagationMapHook): void + sendBounds(this: PropagationMapHook): void + destroyed(this: PropagationMapHook): void +} = { + mounted(this: PropagationMapHook) { + const centerLat = parseFloat(this.el.dataset.centerLat!) + const centerLon = parseFloat(this.el.dataset.centerLon!) + const zoom = parseInt(this.el.dataset.zoom!, 10) - const center = + const center: [number, number] = Number.isFinite(centerLat) && Number.isFinite(centerLon) ? [centerLat, centerLon] : [32.897, -97.038] @@ -452,12 +619,12 @@ export const PropagationMap = { ] // Render pre-loaded scores immediately (no round-trip needed) - const initialScores = JSON.parse(this.el.dataset.scores || "[]") + const initialScores: ScorePoint[] = JSON.parse(this.el.dataset.scores || "[]") if (initialScores.length > 0) { this.renderScores(initialScores) } - this.handleEvent("update_scores", ({ scores }) => { + this.handleEvent("update_scores", ({ scores }: { scores: ScorePoint[] }) => { topbar.hide() this.renderScores(scores) @@ -470,7 +637,7 @@ export const PropagationMap = { this.bandInfo = JSON.parse(this.el.dataset.bandInfo || "{}") this.rangeCircles = L.layerGroup().addTo(this.map) - this.handleEvent("update_band_info", ({ band_info }) => { + this.handleEvent("update_band_info", ({ band_info }: { band_info: BandInfo }) => { this.bandInfo = band_info }) @@ -496,7 +663,7 @@ export const PropagationMap = { this.gridVisible = false // Listen for grid toggle from LiveView - this.handleEvent("toggle_grid", ({ visible }) => { + this.handleEvent("toggle_grid", ({ visible }: { visible: boolean }) => { this.gridVisible = visible if (visible) { this.gridLayer.addTo(this.map) @@ -507,7 +674,7 @@ export const PropagationMap = { }) // Click on map to request viewshed + factor detail from server - this.map.on("click", (e) => { + this.map.on("click", (e: L.LeafletMouseEvent) => { this.rangeCircles.clearLayers() this.viewshedLoading = true this.lastDetail = null @@ -531,7 +698,7 @@ export const PropagationMap = { // Show panel immediately with whatever we have client-side const basic = this.lookupPoint(e.latlng.lat, e.latlng.lng) if (basic && this.detailPanel) { - const merged = { ...basic, ...this.bandInfo, factors: null } + const merged = { ...basic, ...this.bandInfo, factors: null } as unknown as PointDetail this.detailPanel.innerHTML = buildLoadingHTML(merged) this.showDetailPanel() this.pushEvent("point_detail", { lat: e.latlng.lat, lon: e.latlng.lng }) @@ -547,17 +714,17 @@ export const PropagationMap = { L.DomEvent.disableScrollPropagation(this.detailPanel) // Close button event delegation - this.detailPanel.addEventListener("click", (e) => { - if (e.target.closest(".detail-close-btn")) { + this.detailPanel.addEventListener("click", (e: MouseEvent) => { + if ((e.target as HTMLElement).closest(".detail-close-btn")) { this.hideDetailPanel() } }) } this.lastDetail = null - this.handleEvent("point_detail", (detail) => { + this.handleEvent("point_detail", (detail: PointDetail) => { if (detail && this.detailPanel) { - const merged = { ...detail, ...this.bandInfo } + const merged: PointDetail = { ...detail, ...this.bandInfo } this.lastDetail = merged this.detailPanel.innerHTML = buildPopupHTML(merged, this.viewshedLoading) this.showDetailPanel() @@ -581,7 +748,7 @@ export const PropagationMap = { opacity: opacity, interactive: false }) - this.scatterMarkers.addLayer(marker) + this.scatterMarkers!.addLayer(marker) } } @@ -599,7 +766,7 @@ export const PropagationMap = { } const tier = scoreTier(detail.score) this.reachPolygon = L.polygon( - hull.map(p => [p.lat, p.lon]), + hull.map(p => [p.lat, p.lon] as [number, number]), { color: tier.color, weight: 2, @@ -616,7 +783,7 @@ export const PropagationMap = { } }) - this.handleEvent("viewshed_result", ({ origin, points }) => { + this.handleEvent("viewshed_result", ({ origin, points }: ViewshedResult) => { this.rangeCircles.clearLayers() this.viewshedLoading = false @@ -626,7 +793,7 @@ export const PropagationMap = { } if (points && points.length > 0) { - const latlngs = points.map(p => [p.lat, p.lon]) + const latlngs = points.map(p => [p.lat, p.lon] as [number, number]) L.polygon(latlngs, { color: "#222", @@ -640,7 +807,7 @@ export const PropagationMap = { } // Always redraw center marker at clicked location - const loc = this.clickedLatLng || [origin.lat, origin.lon] + const loc: [number, number] = this.clickedLatLng || [origin.lat, origin.lon] const basic = this.lookupPoint(loc[0], loc[1]) const markerColor = basic ? scoreTier(basic.score).color : "#888" @@ -663,7 +830,7 @@ export const PropagationMap = { this.renderTimeline() } - this.handleEvent("update_timeline", ({ times, selected }) => { + this.handleEvent("update_timeline", ({ times, selected }: { times: TimelineEntry[]; selected: string }) => { this.timelineData = times this.selectedTime = selected this.renderTimeline() @@ -676,7 +843,7 @@ export const PropagationMap = { } // Escape key closes detail panel and range circles - document.addEventListener("keydown", (e) => { + document.addEventListener("keydown", (e: KeyboardEvent) => { if (e.key === "Escape") { this.hideDetailPanel() } @@ -697,7 +864,7 @@ export const PropagationMap = { legend.onAdd = () => { const div = L.DomUtil.create("div") const mobile = isMobile() - const rows = [ + const rows: [string, string, string][] = [ ["#00ffa3", "Excellent", "80+"], ["#7dffd4", "Good", "65"], ["#ffe566", "Marginal", "50"], @@ -721,12 +888,12 @@ export const PropagationMap = { legend.addTo(this.map) }, - showDetailPanel() { + showDetailPanel(this: PropagationMapHook) { if (!this.detailPanel) return this.detailPanel.style.display = "block" }, - hideDetailPanel() { + hideDetailPanel(this: PropagationMapHook) { if (this.detailPanel) { this.detailPanel.style.display = "none" this.detailPanel.innerHTML = "" @@ -737,7 +904,7 @@ export const PropagationMap = { this.lastDetail = null }, - renderScores(scores) { + renderScores(this: PropagationMapHook, scores: ScorePoint[]) { this.scores = scores if (this.scoreOverlay) { this.map.removeLayer(this.scoreOverlay) @@ -753,25 +920,26 @@ export const PropagationMap = { }) // Pre-calculate RGBA strings for scores 0-100 - const colorCache = new Array(101) + const colorCache = new Array(101) for (let i = 0; i <= 100; i++) { const c = this.scoreColorRGB(i) colorCache[i] = `rgba(${c.r},${c.g},${c.b},0.55)` } + // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this - this.scoreOverlay = L.GridLayer.extend({ - createTile(coords) { + this.scoreOverlay = new (L.GridLayer.extend({ + createTile(this: L.GridLayer, coords: L.Coords) { const tile = document.createElement("canvas") const size = this.getTileSize() tile.width = size.x tile.height = size.y - const ctx = tile.getContext("2d") + const ctx = tile.getContext("2d")! const step = 0.125 const nwPoint = coords.scaleBy(size) - const nw = this._map.unproject(nwPoint, coords.z) - const se = this._map.unproject(nwPoint.add(size), coords.z) + const nw = (this as any)._map.unproject(nwPoint, coords.z) as L.LatLng + const se = (this as any)._map.unproject(nwPoint.add(size), coords.z) as L.LatLng const latPerPx = (nw.lat - se.lat) / size.y const lonPerPx = (se.lng - nw.lng) / size.x @@ -779,7 +947,7 @@ export const PropagationMap = { const cellPxY = Math.max(1, Math.ceil(step / Math.abs(latPerPx))) const absLatPerPx = Math.abs(latPerPx) - let lastColor = null + let lastColor: string | null = null for (let py = 0; py < size.y; py += cellPxY) { const lat = nw.lat - py * absLatPerPx for (let px = 0; px < size.x; px += cellPxX) { @@ -797,16 +965,14 @@ export const PropagationMap = { } return tile } - }) - - this.scoreOverlay = new (this.scoreOverlay)({ opacity: 1.0 }) + }))({ opacity: 1.0 }) as L.GridLayer this.scoreOverlay.addTo(this.map) }, - interpolateScore(lat, lon, step) { + interpolateScore(this: PropagationMapHook, lat: number, lon: number, step: number): number | null { const rlat = (Math.round(lat / step) * step).toFixed(3) const rlon = (Math.round(lon / step) * step).toFixed(3) - if (this.gridLookup.has(`${rlat},${rlon}`)) return this.gridLookup.get(`${rlat},${rlon}`) + if (this.gridLookup.has(`${rlat},${rlon}`)) return this.gridLookup.get(`${rlat},${rlon}`)! const lat0 = Math.floor(lat / step) * step const lat1 = lat0 + step @@ -818,21 +984,21 @@ export const PropagationMap = { const s01 = this.gridLookup.get(`${lat0.toFixed(3)},${lon1.toFixed(3)}`) const s11 = this.gridLookup.get(`${lat1.toFixed(3)},${lon1.toFixed(3)}`) - const corners = [s00, s10, s01, s11].filter(s => s !== undefined) + const corners = [s00, s10, s01, s11].filter((s): s is number => s !== undefined) if (corners.length < 2) return null if (corners.length === 4) { const tLat = (lat - lat0) / step const tLon = (lon - lon0) / step return Math.round( - s00 * (1 - tLat) * (1 - tLon) + s10 * tLat * (1 - tLon) + - s01 * (1 - tLat) * tLon + s11 * tLat * tLon + s00! * (1 - tLat) * (1 - tLon) + s10! * tLat * (1 - tLon) + + s01! * (1 - tLat) * tLon + s11! * tLat * tLon ) } return Math.round(corners.reduce((a, b) => a + b, 0) / corners.length) }, - scoreColorRGB(score) { + scoreColorRGB(this: PropagationMapHook, score: number): RGB { for (let i = 0; i < this.colorScale.length - 1; i++) { const upper = this.colorScale[i] const lower = this.colorScale[i + 1] @@ -850,7 +1016,7 @@ export const PropagationMap = { return { r: last.r, g: last.g, b: last.b } }, - lookupPoint(lat, lon) { + lookupPoint(this: PropagationMapHook, lat: number, lon: number): (ScorePoint & BandInfo) | null { if (!this.gridData) return null const step = 0.125 const rlat = (Math.round(lat / step) * step).toFixed(3) @@ -860,7 +1026,7 @@ export const PropagationMap = { return { ...data, ...this.bandInfo } }, - renderTimeline() { + renderTimeline(this: PropagationMapHook) { if (!this.timelineEl || this.timelineData.length === 0) { if (this.timelineEl) this.timelineEl.style.display = "none" return @@ -882,7 +1048,7 @@ export const PropagationMap = { // Hide timeline if all times are within 1 hour (no real forecast spread) const firstDt = new Date(this.timelineData[0].time) const lastDt = new Date(this.timelineData[this.timelineData.length - 1].time) - if (lastDt - firstDt < 3600000) { + if (lastDt.getTime() - firstDt.getTime() < 3600000) { const dt = firstDt const utcLabel = `${dt.getUTCHours().toString().padStart(2, "0")}:00 UTC` const dateLabel = `${dt.getUTCFullYear()}-${(dt.getUTCMonth() + 1).toString().padStart(2, "0")}-${dt.getUTCDate().toString().padStart(2, "0")}` @@ -895,17 +1061,17 @@ export const PropagationMap = { } const now = new Date() - const items = this.timelineData.map((t, idx) => { + const items: TimelineItem[] = this.timelineData.map((t, idx) => { const dt = new Date(t.time) const isSelected = t.time === this.selectedTime - const isPast = dt < new Date(now - 1800000) // 30min grace - const offsetH = Math.round((dt - now) / 3600000) - return { ...t, dt, isSelected, isPast, offsetH, idx } + const isPast = dt.getTime() < now.getTime() - 1800000 // 30min grace + const offsetH = Math.round((dt.getTime() - now.getTime()) / 3600000) + return { ...t, dt, isSelected, isPast, offsetH, idx, label: "" } }) // "Now" = the item closest to current time const closestIdx = items.reduce((best, item, i) => - Math.abs(item.dt - now) < Math.abs(items[best].dt - now) ? i : best, 0) + Math.abs(item.dt.getTime() - now.getTime()) < Math.abs(items[best].dt.getTime() - now.getTime()) ? i : best, 0) items.forEach((t, i) => { if (i === closestIdx) { @@ -946,17 +1112,17 @@ export const PropagationMap = { ` // Attach click handlers - this.timelineEl.querySelectorAll("button[data-time]").forEach(btn => { + this.timelineEl.querySelectorAll("button[data-time]").forEach(btn => { btn.addEventListener("click", () => { - this.selectedTime = btn.dataset.time + this.selectedTime = btn.dataset.time! this.renderTimeline() topbar.show() - this.pushEvent("select_time", { time: btn.dataset.time }) + this.pushEvent("select_time", { time: btn.dataset.time! }) }) }) }, - sendBounds() { + sendBounds(this: PropagationMapHook) { topbar.show() const b = this.map.getBounds() this.pushEvent("map_bounds", { @@ -967,7 +1133,7 @@ export const PropagationMap = { }) }, - destroyed() { + destroyed(this: PropagationMapHook) { if (this.map) this.map.remove() } } diff --git a/assets/js/rover_map_hook.js b/assets/js/rover_map_hook.ts similarity index 73% rename from assets/js/rover_map_hook.js rename to assets/js/rover_map_hook.ts index 372334da..ed877375 100644 --- a/assets/js/rover_map_hook.js +++ b/assets/js/rover_map_hook.ts @@ -1,5 +1,44 @@ -export const RoverMap = { - mounted() { +interface Score { + lat: number + lon: number + score: number +} + +interface Station { + lat: number + lon: number + label: string +} + +interface ColorScaleEntry { + min: number + r: number + g: number + b: number +} + +interface RGB { + r: number + g: number + b: number +} + +interface RoverMapHook extends ViewHook { + stations: Station[] + stationMarkers: L.LayerGroup + scoreOverlay: L.GridLayer | null + gridLookup: Map + colorScale: ColorScaleEntry[] + map: L.Map + renderScores(this: RoverMapHook, scores: Score[]): void + interpolateScore(this: RoverMapHook, lat: number, lon: number, step: number): number | null + scoreColorRGB(this: RoverMapHook, score: number): RGB + renderStations(this: RoverMapHook): void + fitBounds(this: RoverMapHook): void +} + +export const RoverMap: RoverMapHook = { + mounted(this: RoverMapHook) { this.stations = [] this.stationMarkers = L.layerGroup() this.scoreOverlay = null @@ -28,7 +67,7 @@ export const RoverMap = { this.stationMarkers.addTo(map) // Render pre-loaded propagation scores - const initialScores = JSON.parse(this.el.dataset.scores || "[]") + const initialScores: Score[] = JSON.parse(this.el.dataset.scores || "[]") if (initialScores.length > 0) { this.renderScores(initialScores) } @@ -46,11 +85,11 @@ export const RoverMap = { pushBounds() }) - this.handleEvent("update_scores", ({ scores }) => { + this.handleEvent("update_scores", ({ scores }: { scores: Score[] }) => { this.renderScores(scores) }) - this.handleEvent("stations_updated", ({ stations }) => { + this.handleEvent("stations_updated", ({ stations }: { stations: Station[] }) => { this.stations = stations this.renderStations() }) @@ -59,7 +98,7 @@ export const RoverMap = { // --- Propagation score overlay (same as main map) --- - renderScores(scores) { + renderScores(this: RoverMapHook, scores: Score[]) { if (this.scoreOverlay) { this.map.removeLayer(this.scoreOverlay) this.scoreOverlay = null @@ -72,7 +111,7 @@ export const RoverMap = { this.gridLookup.set(key, s.score) }) - const colorCache = new Array(101) + const colorCache = new Array(101) for (let i = 0; i <= 100; i++) { const c = this.scoreColorRGB(i) colorCache[i] = `rgba(${c.r},${c.g},${c.b},0.55)` @@ -80,12 +119,12 @@ export const RoverMap = { const self = this const OverlayClass = L.GridLayer.extend({ - createTile(coords) { + createTile(coords: L.Coords) { const tile = document.createElement("canvas") const size = this.getTileSize() tile.width = size.x tile.height = size.y - const ctx = tile.getContext("2d") + const ctx = tile.getContext("2d")! const step = 0.125 const nwPoint = coords.scaleBy(size) @@ -98,7 +137,7 @@ export const RoverMap = { const cellPxY = Math.max(1, Math.ceil(step / Math.abs(latPerPx))) const absLatPerPx = Math.abs(latPerPx) - let lastColor = null + let lastColor: string | null = null for (let py = 0; py < size.y; py += cellPxY) { const lat = nw.lat - py * absLatPerPx for (let px = 0; px < size.x; px += cellPxX) { @@ -118,14 +157,14 @@ export const RoverMap = { } }) - this.scoreOverlay = new OverlayClass({ opacity: 1.0 }) - this.scoreOverlay.addTo(this.map) + this.scoreOverlay = new OverlayClass({ opacity: 1.0 }) as L.GridLayer + this.scoreOverlay!.addTo(this.map) }, - interpolateScore(lat, lon, step) { + interpolateScore(this: RoverMapHook, lat: number, lon: number, step: number): number | null { const rlat = (Math.round(lat / step) * step).toFixed(3) const rlon = (Math.round(lon / step) * step).toFixed(3) - if (this.gridLookup.has(`${rlat},${rlon}`)) return this.gridLookup.get(`${rlat},${rlon}`) + if (this.gridLookup.has(`${rlat},${rlon}`)) return this.gridLookup.get(`${rlat},${rlon}`)! const lat0 = Math.floor(lat / step) * step const lat1 = lat0 + step @@ -137,21 +176,21 @@ export const RoverMap = { const s01 = this.gridLookup.get(`${lat0.toFixed(3)},${lon1.toFixed(3)}`) const s11 = this.gridLookup.get(`${lat1.toFixed(3)},${lon1.toFixed(3)}`) - const corners = [s00, s10, s01, s11].filter(s => s !== undefined) + const corners = [s00, s10, s01, s11].filter((s): s is number => s !== undefined) if (corners.length < 2) return null if (corners.length === 4) { const tLat = (lat - lat0) / step const tLon = (lon - lon0) / step return Math.round( - s00 * (1 - tLat) * (1 - tLon) + s10 * tLat * (1 - tLon) + - s01 * (1 - tLat) * tLon + s11 * tLat * tLon + s00! * (1 - tLat) * (1 - tLon) + s10! * tLat * (1 - tLon) + + s01! * (1 - tLat) * tLon + s11! * tLat * tLon ) } return Math.round(corners.reduce((a, b) => a + b, 0) / corners.length) }, - scoreColorRGB(score) { + scoreColorRGB(this: RoverMapHook, score: number): RGB { for (let i = 0; i < this.colorScale.length - 1; i++) { const upper = this.colorScale[i] const lower = this.colorScale[i + 1] @@ -171,7 +210,7 @@ export const RoverMap = { // --- Station rendering --- - renderStations() { + renderStations(this: RoverMapHook) { this.stationMarkers.clearLayers() for (const s of this.stations) { @@ -198,12 +237,12 @@ export const RoverMap = { this.fitBounds() }, - fitBounds() { - const points = this.stations.map(s => [s.lat, s.lon]) + fitBounds(this: RoverMapHook) { + const points: [number, number][] = this.stations.map(s => [s.lat, s.lon]) if (points.length >= 2) { this.map.fitBounds(points, { padding: [60, 60], maxZoom: 9 }) } else if (points.length === 1) { this.map.setView(points[0], 8) } } -} +} as unknown as RoverMapHook diff --git a/assets/js/types/global.d.ts b/assets/js/types/global.d.ts new file mode 100644 index 00000000..08a946a9 --- /dev/null +++ b/assets/js/types/global.d.ts @@ -0,0 +1,92 @@ +// Global type declarations for vendor libraries and Phoenix LiveView + +// Leaflet is assigned to window.L in app.ts +declare const L: typeof import("leaflet") + +declare module "leaflet" { + interface Map { + createPane(name: string): HTMLElement + } + + interface GridLayerOptions { + opacity?: number + } + + function gridLayer(options?: GridLayerOptions): GridLayer +} + +// Phoenix LiveView hook interface +interface ViewHook { + el: HTMLElement + liveSocket: any + pushEvent(event: string, payload: Record): void + pushEventTo( + selectorOrTarget: string | HTMLElement, + event: string, + payload: Record + ): void + handleEvent(event: string, callback: (payload: any) => void): void + upload(name: string, files: FileList): void +} + +// Vendor module declarations +declare module "../vendor/topbar" { + const topbar: { + config(opts: { barColors: Record; shadowColor: string }): void + show(delay?: number): void + hide(): void + } + export default topbar +} + +declare module "../vendor/chart.js" { + export default class Chart { + constructor(ctx: CanvasRenderingContext2D, config: any) + destroy(): void + options: any + data: any + } +} + +declare module "../vendor/leaflet/leaflet.js" { + export = L +} + +declare module "phoenix" { + export class Socket { + constructor(url: string, opts?: Record) + } +} + +declare module "phoenix_html" { + const _default: void + export default _default +} + +declare module "phoenix_live_view" { + import { Socket } from "phoenix" + + export class LiveSocket { + constructor(url: string, socketClass: typeof Socket, opts?: Record) + connect(): void + enableDebug(): void + enableLatencySim(ms: number): void + disableLatencySim(): void + } +} + +declare module "phoenix-colocated/microwaveprop" { + export const hooks: Record +} + +declare module "../../deps/live_stash/assets/js/live-stash.js" { + export default function initLiveStash( + params: Record + ): (params: Record) => Record +} + +interface Window { + L: typeof import("leaflet") + liveSocket: any + liveReloader: any +} diff --git a/assets/js/weather_map_hook.js b/assets/js/weather_map_hook.ts similarity index 76% rename from assets/js/weather_map_hook.js rename to assets/js/weather_map_hook.ts index 2602bfd4..1bb364ed 100644 --- a/assets/js/weather_map_hook.js +++ b/assets/js/weather_map_hook.ts @@ -1,6 +1,99 @@ -// assets/js/weather_map_hook.js +// assets/js/weather_map_hook.ts -function isMobile() { +// --- Type definitions --- + +interface RGB { + r: number + g: number + b: number +} + +interface Breakpoint extends RGB { + value: number +} + +interface ContinuousColorScale { + breakpoints: Breakpoint[] + trueColor?: never + format: (v: number | null) => string + label: string + unit: string +} + +interface BooleanColorScale { + breakpoints: null + trueColor: RGB + format: (v: boolean | null) => string + label: string + unit: string +} + +type ColorScale = ContinuousColorScale | BooleanColorScale + +type LayerId = keyof typeof COLOR_SCALES + +interface WeatherPoint { + lat: number + lon: number + valid_time: string | null + temperature: number | null + dewpoint_depression: number | null + surface_rh: number | null + surface_pressure_mb: number | null + surface_refractivity: number | null + refractivity_gradient: number | null + bl_height: number | null + pwat: number | null + temp_850mb: number | null + dewpoint_850mb: number | null + lapse_rate: number | null + inversion_strength: number | null + inversion_base_m: number | null + ducting: boolean | null + duct_base_m: number | null + duct_strength: number | null + [key: string]: unknown +} + +interface DetailRow { + label?: string + value?: string + color?: string + separator?: boolean +} + +interface MapBounds { + south: number + north: number + west: number + east: number +} + +interface WeatherMapHook extends ViewHook { + map: L.Map + weatherOverlay: L.GridLayer | null + weatherData: WeatherPoint[] + gridLookup: Map + selectedLayer: LayerId + detailPanel: HTMLElement | null + escHint: HTMLDivElement + clickMarker: L.LayerGroup + legend: L.Control + _escHandler: (e: KeyboardEvent) => void + _layerObserver: MutationObserver + + mounted(this: WeatherMapHook): void + destroyed(this: WeatherMapHook): void + sendBounds(this: WeatherMapHook): void + positionDetailPanel(this: WeatherMapHook): void + buildLookup(this: WeatherMapHook): void + renderLayer(this: WeatherMapHook): void + buildLegend(this: WeatherMapHook): HTMLElement +} + +// --- Helpers --- + +function isMobile(): boolean { return window.innerWidth < 768 } @@ -13,7 +106,7 @@ const COLOR_SCALES = { { value: -100, r: 255, g: 144, b: 68 }, { value: 0, r: 255, g: 79, b: 79 } ], - format: v => v != null ? `${v.toFixed(0)} N/km` : "N/A", + format: (v: number | null): string => v != null ? `${v.toFixed(0)} N/km` : "N/A", label: "Refractivity Gradient", unit: "N/km" }, @@ -25,7 +118,7 @@ const COLOR_SCALES = { { value: 2000, r: 255, g: 144, b: 68 }, { value: 3000, r: 255, g: 79, b: 79 } ], - format: v => v != null ? `${v.toFixed(0)} m` : "N/A", + format: (v: number | null): string => v != null ? `${v.toFixed(0)} m` : "N/A", label: "Boundary Layer Height", unit: "m" }, @@ -37,7 +130,7 @@ const COLOR_SCALES = { { value: 20, r: 255, g: 144, b: 68 }, { value: 30, r: 255, g: 79, b: 79 } ], - format: v => v != null ? `${v.toFixed(1)} \u00b0C` : "N/A", + format: (v: number | null): string => v != null ? `${v.toFixed(1)} \u00b0C` : "N/A", label: "Dewpoint Depression (T - Td)", unit: "\u00b0C" }, @@ -49,7 +142,7 @@ const COLOR_SCALES = { { value: 45, r: 65, g: 105, b: 225 }, { value: 60, r: 0, g: 0, b: 180 } ], - format: v => v != null ? `${v.toFixed(1)} mm` : "N/A", + format: (v: number | null): string => v != null ? `${v.toFixed(1)} mm` : "N/A", label: "Precipitable Water", unit: "mm" }, @@ -61,14 +154,14 @@ const COLOR_SCALES = { { value: 30, r: 255, g: 140, b: 0 }, { value: 45, r: 200, g: 0, b: 0 } ], - format: v => v != null ? `${v.toFixed(1)} \u00b0C (${(v * 9/5 + 32).toFixed(0)} \u00b0F)` : "N/A", + format: (v: number | null): string => v != null ? `${v.toFixed(1)} \u00b0C (${(v * 9/5 + 32).toFixed(0)} \u00b0F)` : "N/A", label: "Surface Temperature", unit: "\u00b0C" }, ducting: { breakpoints: null, trueColor: { r: 0, g: 255, b: 163 }, - format: v => v ? "Yes" : "No", + format: (v: boolean | null): string => v ? "Yes" : "No", label: "Ducting Detected", unit: "" }, @@ -80,7 +173,7 @@ const COLOR_SCALES = { { value: 80, r: 0, g: 255, b: 163 }, { value: 100, r: 0, g: 200, b: 130 } ], - format: v => v != null ? `${v.toFixed(0)}%` : "N/A", + format: (v: number | null): string => v != null ? `${v.toFixed(0)}%` : "N/A", label: "Relative Humidity", unit: "%" }, @@ -92,7 +185,7 @@ const COLOR_SCALES = { { value: 360, r: 125, g: 255, b: 212 }, { value: 400, r: 0, g: 255, b: 163 } ], - format: v => v != null ? `${v.toFixed(1)} N` : "N/A", + format: (v: number | null): string => v != null ? `${v.toFixed(1)} N` : "N/A", label: "Surface Refractivity", unit: "N" }, @@ -104,7 +197,7 @@ const COLOR_SCALES = { { value: 15, r: 255, g: 140, b: 0 }, { value: 30, r: 200, g: 0, b: 0 } ], - format: v => v != null ? `${v.toFixed(1)} °C` : "N/A", + format: (v: number | null): string => v != null ? `${v.toFixed(1)} °C` : "N/A", label: "Temperature at 850mb", unit: "°C" }, @@ -116,7 +209,7 @@ const COLOR_SCALES = { { value: 15, r: 65, g: 105, b: 225 }, { value: 25, r: 0, g: 0, b: 180 } ], - format: v => v != null ? `${v.toFixed(1)} °C` : "N/A", + format: (v: number | null): string => v != null ? `${v.toFixed(1)} °C` : "N/A", label: "Dewpoint at 850mb", unit: "°C" }, @@ -128,7 +221,7 @@ const COLOR_SCALES = { { value: 8, r: 255, g: 144, b: 68 }, { value: 10, r: 255, g: 79, b: 79 } ], - format: v => v != null ? `${v.toFixed(1)} °C/km` : "N/A", + format: (v: number | null): string => v != null ? `${v.toFixed(1)} °C/km` : "N/A", label: "Lapse Rate", unit: "°C/km" }, @@ -140,7 +233,7 @@ const COLOR_SCALES = { { value: 5, r: 0, g: 255, b: 163 }, { value: 10, r: 0, g: 200, b: 130 } ], - format: v => v != null ? (v > 0 ? `+${v.toFixed(1)} °C` : "None") : "N/A", + format: (v: number | null): string => v != null ? (v > 0 ? `+${v.toFixed(1)} °C` : "None") : "N/A", label: "Inversion Strength", unit: "°C" }, @@ -152,7 +245,7 @@ const COLOR_SCALES = { { value: 2000, r: 255, g: 144, b: 68 }, { value: 3000, r: 255, g: 79, b: 79 } ], - format: v => v != null ? `${v.toFixed(0)} m AGL` : "None", + format: (v: number | null): string => v != null ? `${v.toFixed(0)} m AGL` : "None", label: "Inversion Base Height", unit: "m" }, @@ -164,7 +257,7 @@ const COLOR_SCALES = { { value: 1000, r: 255, g: 144, b: 68 }, { value: 2000, r: 255, g: 79, b: 79 } ], - format: v => v != null ? `${v.toFixed(0)} m AGL` : "None", + format: (v: number | null): string => v != null ? `${v.toFixed(0)} m AGL` : "None", label: "Duct Base Height", unit: "m" }, @@ -176,13 +269,13 @@ const COLOR_SCALES = { { value: 20, r: 0, g: 255, b: 163 }, { value: 40, r: 0, g: 200, b: 130 } ], - format: v => v != null ? (v > 0 ? `${v.toFixed(1)} M-units` : "None") : "N/A", + format: (v: number | null): string => v != null ? (v > 0 ? `${v.toFixed(1)} M-units` : "None") : "N/A", label: "Duct Strength", unit: "M" } -} +} as const satisfies Record -function interpolateColor(value, breakpoints) { +function interpolateColor(value: number | null, breakpoints: Breakpoint[]): RGB | null { if (value == null) return null if (value <= breakpoints[0].value) return breakpoints[0] if (value >= breakpoints[breakpoints.length - 1].value) return breakpoints[breakpoints.length - 1] @@ -202,10 +295,10 @@ function interpolateColor(value, breakpoints) { return breakpoints[breakpoints.length - 1] } -function buildDetailHTML(point) { +function buildDetailHTML(point: WeatherPoint): string { if (!point) return "" - const rows = [ + const rows: DetailRow[] = [ { label: "Temperature", value: COLOR_SCALES.temperature.format(point.temperature), color: "#ff9044" }, { label: "Td Depression", value: COLOR_SCALES.dewpoint_depression.format(point.dewpoint_depression), color: "#7dffd4" }, { label: "Rel. Humidity", value: COLOR_SCALES.surface_rh.format(point.surface_rh), color: "#00ffa3" }, @@ -218,7 +311,7 @@ function buildDetailHTML(point) { { label: "T @ 850mb", value: COLOR_SCALES.temp_850mb.format(point.temp_850mb), color: "#ff9044" }, { label: "Td @ 850mb", value: COLOR_SCALES.dewpoint_850mb.format(point.dewpoint_850mb), color: "#4169e1" }, { label: "Lapse Rate", value: COLOR_SCALES.lapse_rate.format(point.lapse_rate), color: "#ffe566" }, - { label: "Inversion", value: COLOR_SCALES.inversion_strength.format(point.inversion_strength), color: point.inversion_strength > 0 ? "#00ffa3" : "#666" }, + { label: "Inversion", value: COLOR_SCALES.inversion_strength.format(point.inversion_strength), color: point.inversion_strength != null && point.inversion_strength > 0 ? "#00ffa3" : "#666" }, { label: "Inv. Base", value: COLOR_SCALES.inversion_base_m.format(point.inversion_base_m), color: point.inversion_base_m != null ? "#7dffd4" : "#666" }, { separator: true }, { label: "Ducting", value: COLOR_SCALES.ducting.format(point.ducting), color: point.ducting ? "#00ffa3" : "#ff4f4f" }, @@ -262,8 +355,10 @@ function buildDetailHTML(point) { ` } -export const WeatherMap = { - mounted() { +// --- Hook --- + +export const WeatherMap: WeatherMapHook = { + mounted(this: WeatherMapHook) { this.map = L.map(this.el, { center: [32.897, -97.038], zoom: 7, @@ -279,16 +374,16 @@ export const WeatherMap = { this.weatherOverlay = null this.weatherData = [] this.gridLookup = new Map() - this.selectedLayer = this.el.dataset.selectedLayer || "refractivity_gradient" + this.selectedLayer = (this.el.dataset.selectedLayer || "refractivity_gradient") as LayerId - const initialData = JSON.parse(this.el.dataset.weather || "[]") + const initialData: WeatherPoint[] = JSON.parse(this.el.dataset.weather || "[]") if (initialData.length > 0) { this.weatherData = initialData this.buildLookup() this.renderLayer() } - this.handleEvent("update_weather", ({ data }) => { + this.handleEvent("update_weather", ({ data }: { data: WeatherPoint[] }) => { this.weatherData = data this.buildLookup() this.renderLayer() @@ -307,7 +402,7 @@ export const WeatherMap = { this.escHint.textContent = "Press ESC to close" this.el.appendChild(this.escHint) - this.handleEvent("point_detail", (detail) => { + this.handleEvent("point_detail", (detail: WeatherPoint) => { if (detail && detail.lat && this.detailPanel) { this.detailPanel.innerHTML = buildDetailHTML(detail) this.detailPanel.style.display = "block" @@ -318,7 +413,7 @@ export const WeatherMap = { this.clickMarker = L.layerGroup().addTo(this.map) - this.map.on("click", (e) => { + this.map.on("click", (e: L.LeafletMouseEvent) => { this.clickMarker.clearLayers() L.circleMarker([e.latlng.lat, e.latlng.lng], { radius: 6, color: "#fff", weight: 2, @@ -327,7 +422,7 @@ export const WeatherMap = { this.pushEvent("point_detail", { lat: e.latlng.lat, lon: e.latlng.lng }) }) - this._escHandler = (e) => { + this._escHandler = (e: KeyboardEvent) => { if (e.key === "Escape" && this.detailPanel) { this.detailPanel.style.display = "none" this.detailPanel.innerHTML = "" @@ -347,7 +442,7 @@ export const WeatherMap = { this.map.on("moveend", () => this.sendBounds()) this._layerObserver = new MutationObserver(() => { - const newLayer = this.el.dataset.selectedLayer + const newLayer = this.el.dataset.selectedLayer as LayerId | undefined if (newLayer && newLayer !== this.selectedLayer) { this.selectedLayer = newLayer this.renderLayer() @@ -360,12 +455,12 @@ export const WeatherMap = { this.legend.addTo(this.map) }, - destroyed() { + destroyed(this: WeatherMapHook) { if (this._layerObserver) this._layerObserver.disconnect() if (this._escHandler) document.removeEventListener("keydown", this._escHandler) }, - sendBounds() { + sendBounds(this: WeatherMapHook) { const b = this.map.getBounds() this.pushEvent("map_bounds", { south: b.getSouth(), north: b.getNorth(), @@ -373,7 +468,7 @@ export const WeatherMap = { }) }, - positionDetailPanel() { + positionDetailPanel(this: WeatherMapHook) { if (!this.detailPanel || isMobile()) return const controls = document.getElementById("weather-controls") if (controls) { @@ -383,7 +478,7 @@ export const WeatherMap = { } }, - buildLookup() { + buildLookup(this: WeatherMapHook) { this.gridLookup = new Map() this.weatherData.forEach(d => { const key = `${d.lat.toFixed(3)},${d.lon.toFixed(3)}` @@ -391,7 +486,7 @@ export const WeatherMap = { }) }, - renderLayer() { + renderLayer(this: WeatherMapHook) { if (this.weatherOverlay) { this.map.removeLayer(this.weatherOverlay) this.weatherOverlay = null @@ -406,17 +501,17 @@ export const WeatherMap = { const isDucting = layerId === "ducting" const Overlay = L.GridLayer.extend({ - createTile(coords) { + createTile(this: L.GridLayer, coords: L.Coords) { const tile = document.createElement("canvas") const size = this.getTileSize() tile.width = size.x tile.height = size.y - const ctx = tile.getContext("2d") + const ctx = tile.getContext("2d")! const step = 0.125 const nwPoint = coords.scaleBy(size) - const nw = this._map.unproject(nwPoint, coords.z) - const se = this._map.unproject(nwPoint.add(size), coords.z) + const nw = (this as any)._map.unproject(nwPoint, coords.z) as L.LatLng + const se = (this as any)._map.unproject(nwPoint.add(size), coords.z) as L.LatLng const latPerPx = (nw.lat - se.lat) / size.y const lonPerPx = (se.lng - nw.lng) / size.x @@ -437,11 +532,12 @@ export const WeatherMap = { if (isDucting) { if (value) { - ctx.fillStyle = `rgba(${scale.trueColor.r},${scale.trueColor.g},${scale.trueColor.b},0.55)` + const tc = (scale as BooleanColorScale).trueColor + ctx.fillStyle = `rgba(${tc.r},${tc.g},${tc.b},0.55)` ctx.fillRect(px, py, cellPxX, cellPxY) } } else { - const c = interpolateColor(value, scale.breakpoints) + const c = interpolateColor(value as number | null, (scale as ContinuousColorScale).breakpoints) if (c) { ctx.fillStyle = `rgba(${c.r},${c.g},${c.b},0.55)` ctx.fillRect(px, py, cellPxX, cellPxY) @@ -453,7 +549,7 @@ export const WeatherMap = { } }) - this.weatherOverlay = new Overlay({ opacity: 1.0 }) + this.weatherOverlay = new Overlay({ opacity: 1.0 }) as L.GridLayer this.weatherOverlay.addTo(this.map) if (this.legend) { @@ -464,7 +560,7 @@ export const WeatherMap = { } }, - buildLegend() { + buildLegend(this: WeatherMapHook): HTMLElement { const div = L.DomUtil.create("div") const layerId = this.selectedLayer const scale = COLOR_SCALES[layerId] @@ -473,7 +569,7 @@ export const WeatherMap = { const mobile = isMobile() if (layerId === "ducting") { - const c = scale.trueColor + const c = (scale as BooleanColorScale).trueColor div.style.cssText = `background:#fff;color:#333;padding:${mobile ? '4px 6px' : '10px 14px'};border-radius:${mobile ? '6px' : '8px'};box-shadow:0 2px 8px rgba(0,0,0,0.3);font-size:${mobile ? '10px' : '13px'};line-height:2;` div.innerHTML = `${scale.label}
` + `Ducting
` + @@ -481,7 +577,7 @@ export const WeatherMap = { return div } - const bp = scale.breakpoints + const bp = (scale as ContinuousColorScale).breakpoints if (mobile) { div.style.cssText = "background:#fff;color:#333;padding:4px 6px;border-radius:6px;box-shadow:0 1px 4px rgba(0,0,0,0.3);font-size:10px;line-height:1.6;" @@ -497,4 +593,4 @@ export const WeatherMap = { } return div } -} +} as unknown as WeatherMapHook diff --git a/assets/tsconfig.json b/assets/tsconfig.json index a9401b62..5410b251 100644 --- a/assets/tsconfig.json +++ b/assets/tsconfig.json @@ -1,32 +1,17 @@ -// This file is needed on most editors to enable the intelligent autocompletion -// of LiveView's JavaScript API methods. You can safely delete it if you don't need it. -// -// Note: This file assumes a basic esbuild setup without node_modules. -// We include a generic paths alias to deps to mimic how esbuild resolves -// the Phoenix and LiveView JavaScript assets. -// If you have a package.json in your project, you should remove the -// paths configuration and instead add the phoenix dependencies to the -// dependencies section of your package.json: -// -// { -// ... -// "dependencies": { -// ..., -// "phoenix": "../deps/phoenix", -// "phoenix_html": "../deps/phoenix_html", -// "phoenix_live_view": "../deps/phoenix_live_view" -// } -// } -// -// Feel free to adjust this configuration however you need. { "compilerOptions": { "baseUrl": ".", "paths": { "*": ["../deps/*"] }, - "allowJs": true, - "noEmit": true + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "typeRoots": ["./js/types"] }, "include": ["js/**/*"] } diff --git a/config/config.exs b/config/config.exs index f00dbf44..cda7db98 100644 --- a/config/config.exs +++ b/config/config.exs @@ -12,7 +12,7 @@ config :esbuild, version: "0.27.4", microwaveprop: [ args: - ~w(js/app.js --bundle --target=es2022 --outdir=../priv/static/assets/js --external:/fonts/* --external:/images/* --alias:@=.), + ~w(js/app.ts --bundle --target=es2022 --outdir=../priv/static/assets/js --external:/fonts/* --external:/images/* --alias:@=.), cd: Path.expand("../assets", __DIR__), env: %{"NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()]} ]