Convert all JavaScript to TypeScript with type annotations

- Rename all .js files to .ts in assets/js/
- Add interfaces for hook state, data structures, and event payloads
- Add type annotations to function parameters and return types
- Create type declarations for vendor deps (Leaflet, Chart.js, Phoenix, topbar)
- Update tsconfig.json for strict TypeScript checking
- Update esbuild entry point to app.ts
This commit is contained in:
Graham McIntire 2026-04-11 16:52:27 -05:00
parent 9b4d9d08dd
commit 1f368f9b61
13 changed files with 768 additions and 277 deletions

View file

@ -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 `<link>` 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<typeof setInterval>
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
})
}

View file

@ -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 {

View file

@ -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"

View file

@ -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<number>
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<number, string> = {
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<string, number> = {}
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)

View file

@ -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<DuctSource, string> = {hrrr: "Duct", sounding: "Duct (sounding)", inferred: "Est. duct"}
const units: Record<DuctSource, string> = {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

View file

@ -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)
},

View file

@ -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<string>,
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<string>()
const bounds = map.getBounds()
const zoom = map.getZoom()
const showSquares = zoom >= 7

View file

@ -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<string, number> & { 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<string, number>
gridData: Map<string, ScorePoint>
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<string, FactorMeta> = {
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 `<span style="color:${tier.color}">${"\u2588".repeat(filled)}</span><span style="color:rgba(255,255,255,0.2)">${"\u2591".repeat(empty)}</span>`
}
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<string, number>, 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<string>()
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 `<div style="display:flex;justify-content:center;padding:6px 0 2px;"><div style="width:32px;height:4px;border-radius:2px;background:rgba(255,255,255,0.3);"></div></div>`
}
function buildLoadingHTML(detail) {
function buildLoadingHTML(detail: PointDetail): string {
const tier = scoreTier(detail.score)
return `<div style="position:relative;">
${mobileHandleHTML()}
@ -230,7 +385,7 @@ function buildLoadingHTML(detail) {
</div>`
}
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) {
</div>`
}
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 += `<tr>
@ -364,10 +519,10 @@ function buildPopupHTML(detail, viewshedLoading) {
</div>`
}
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<string, string> = { 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) {
</div>`
}
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<string, unknown> & {
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<string>(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 = {
</div>`
// Attach click handlers
this.timelineEl.querySelectorAll("button[data-time]").forEach(btn => {
this.timelineEl.querySelectorAll<HTMLButtonElement>("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()
}
}

View file

@ -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<string, number>
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<string>(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

92
assets/js/types/global.d.ts vendored Normal file
View file

@ -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<string, unknown>): void
pushEventTo(
selectorOrTarget: string | HTMLElement,
event: string,
payload: Record<string, unknown>
): 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<number, string>; 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<string, unknown>)
}
}
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<string, unknown>)
connect(): void
enableDebug(): void
enableLatencySim(ms: number): void
disableLatencySim(): void
}
}
declare module "phoenix-colocated/microwaveprop" {
export const hooks: Record<string, object>
}
declare module "../../deps/live_stash/assets/js/live-stash.js" {
export default function initLiveStash(
params: Record<string, unknown>
): (params: Record<string, unknown>) => Record<string, unknown>
}
interface Window {
L: typeof import("leaflet")
liveSocket: any
liveReloader: any
}

View file

@ -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<string, WeatherPoint>
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<string, ColorScale>
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) {
</div>`
}
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 = `<strong style="color:#333;">${scale.label}</strong><br>` +
`<span style="background:rgb(${c.r},${c.g},${c.b});width:14px;height:14px;display:inline-block;border-radius:50%;vertical-align:middle;margin-right:6px;border:1px solid rgba(0,0,0,0.15);"></span>Ducting<br>` +
@ -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

View file

@ -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/**/*"]
}

View file

@ -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()]}
]