Icon caching

This commit is contained in:
2026-06-26 20:52:54 -04:00
parent aeffa30417
commit 73f1453daf
15 changed files with 29 additions and 60 deletions

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import Loading from '@/components/Loading.vue';
import {inject, onMounted, ref} from 'vue';
import {api} from '../services/api';
import {api, BASE} from '../services/api';
const data = ref<any>();
const openGraph = inject<(key: string) => void>('openGraph');
@@ -80,7 +80,7 @@ onMounted(async () => {
<div class="flex-r align-items-center justify-between">
<div v-if="!data" class="pos-rel br-2 overflow-hidden" style="width: 75px; height: 50px"><Loading/></div>
<div v-else class="flex-c justify-center align-items-center mx-3">
<img :src="'https://openweathermap.org/img/wn/' + data.icon + '@2x.png'" class="today-icon" alt="weather" />
<img :src="BASE + '/api/icon/' + data.icon" class="today-icon" alt="weather" />
<div class="today-label">{{ data.label }}</div>
</div>
<div class="flex-r align-items-center gap-2">

View File

@@ -67,7 +67,7 @@ onMounted(async () => {
<div v-if="days?.length" class="strip">
<div v-for="d in days" class="day">
<div class="day-name">{{ formatDate('ddd, MMM D', d.time) }}</div>
<img :src="BASE + d.icon" class="day-icon" :alt="(d.label?.toString() || '')" />
<img :src="BASE + '/api/icon/' + d.icon" class="day-icon" :alt="(d.label?.toString() || '')" />
<div class="day-label">{{ d.label }}</div>
<div class="day-temps">
<span>{{ ~~(d.temperature_max) + '°' || '—' }}</span>

View File

@@ -87,7 +87,7 @@ onMounted(async () => {
<template v-if="hours?.length">
<div v-for="h in hours" class="hour-row">
<span class="hour-time">{{ formatDate('h A', h.time) }}</span>
<img :src="BASE + h.icon" class="hour-icon" :alt="h.label?.toString() || ''" />
<img :src="BASE + '/api/icon/' + h.icon" class="hour-icon" :alt="h.label?.toString() || ''" />
<span class="hour-label">{{ h.label }}</span>
<div class="hour-meta">
<span>🌧 {{ (h.precipitation as any || 0).toFixed(1) }}mm</span>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 948 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 945 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -145,7 +145,6 @@ export async function get24HourForecast(currentSensors) {
};
Object.assign(snapshot, getWeatherCondition(snapshot));
snapshot.icon = `/icons/${snapshot.icon}.png`;
return snapshot;
});

View File

@@ -33,17 +33,6 @@ const OWM_ICONS = {
95: '11d', 96: '11d', 99: '11d',
}
async function ensureIcon(code) {
const owmCode = OWM_ICONS[code] || '01d'
const filename = `${owmCode}.png`
const filepath = resolve(ICON_DIR, filename)
if (!existsSync(filepath)) {
const res = await fetch(`https://openweathermap.org/img/wn/${owmCode}@2x.png`)
await pipeline(res.body, createWriteStream(filepath))
}
return `/icons/${filename}`
}
async function cachedFetch(key, url) {
const now = Date.now()
if (CACHE[key] && now - CACHE[key].ts < CACHE_MS) return CACHE[key].data

View File

@@ -1,48 +1,22 @@
import {existsSync, mkdirSync, writeFileSync} from 'fs';
import {resolve, dirname} from 'path';
import {fileURLToPath} from 'url';
import * as path from 'node:path';
const DIR = resolve(dirname(fileURLToPath(import.meta.url)), 'public', 'icons');
const DIR = resolve(dirname(fileURLToPath(import.meta.url)), '../data/icons');
export const WMO = {
0: {label: 'Clear Sky', icon: '01d'},
1: {label: 'Mainly Clear', icon: '01d'},
2: {label: 'Partly Cloudy', icon: '02d'},
3: {label: 'Overcast', icon: '04d'},
45: {label: 'Fog', icon: '50d'},
48: {label: 'Icy Fog', icon: '50d'},
51: {label: 'Light Drizzle', icon: '09d'},
53: {label: 'Drizzle', icon: '09d'},
55: {label: 'Heavy Drizzle', icon: '09d'},
61: {label: 'Light Rain', icon: '10d'},
63: {label: 'Rain', icon: '10d'},
65: {label: 'Heavy Rain', icon: '10d'},
71: {label: 'Light Snow', icon: '13d'},
73: {label: 'Snow', icon: '13d'},
75: {label: 'Heavy Snow', icon: '13d'},
77: {label: 'Snow Grains', icon: '13d'},
80: {label: 'Light Showers', icon: '09d'},
81: {label: 'Showers', icon: '09d'},
82: {label: 'Heavy Showers', icon: '09d'},
85: {label: 'Snow Showers', icon: '13d'},
86: {label: 'Heavy Snow Showers', icon: '13d'},
95: {label: 'Thunderstorm', icon: '11d'},
96: {label: 'Thunderstorm w/ Hail', icon: '11d'},
99: {label: 'Thunderstorm w/ Hail', icon: '11d'},
};
export async function downloadIcon(icon, path) {
if(!existsSync(DIR)) mkdirSync(dirname(path), {recursive: true});
const res = await fetch(`https://openweathermap.org/img/wn/${icon}@2x.png`);
const buf = Buffer.from(await res.arrayBuffer());
writeFileSync(path, buf);
}
export async function downloadIcons() {
if(!existsSync(DIR)) mkdirSync(DIR, {recursive: true});
const icons = new Set(Object.values(WMO).map(w => [w.icon, w.icon.replace('d', 'n')]).flat());
await Promise.all([...icons].map(async icon => {
const path = resolve(DIR, `${icon}.png`);
if(existsSync(path)) return;
const res = await fetch(`https://openweathermap.org/img/wn/${icon}@2x.png`);
const buf = Buffer.from(await res.arrayBuffer());
writeFileSync(path, buf);
console.log(` 📥 Downloaded icon: ${icon}.png`);
}));
console.log('✅ Weather icons ready');
export async function fetchIcon(icon) {
const iconPath = path.join(DIR, icon + '.png');
if(!existsSync(DIR) || !existsSync(iconPath))
await downloadIcon(icon, iconPath);
return iconPath;
}
export function getWeatherCondition(data) {

View File

@@ -4,14 +4,13 @@ import {fileURLToPath} from 'url';
import {cfg, localDateStr} from './config.mjs';
import {queryCurrent, queryHourly, queryDaily, getCoords} from './influx.mjs';
import {getCelestialCurrent, getCelestialForecast} from './celestial.mjs';
import {getSpaceWeather} from './space.mjs';
import {apiReference} from '@scalar/express-api-reference';
import {spec} from './spec.mjs';
import {existsSync} from 'fs';
import {getAIS} from './ais.mjs';
import {getADSB, getADSBHistory, getADSBRange, initAircraftDb} from './adsb.mjs';
import {getWeatherCondition} from './openweather.mjs';
import {lastForecast, getForecast, forecastTTL, get24HourForecast} from './forecast.mjs';
import {fetchIcon, getWeatherCondition} from './openweather.mjs';
import {lastForecast, getForecast, forecastTTL} from './forecast.mjs';
import {dailyWeather, hourlyWeather} from './openmeteo.mjs';
// import {Aurora} from './aurora.mjs';
@@ -30,7 +29,6 @@ const DIR = dirname(fileURLToPath(import.meta.url));
const CLIENT_DIST = resolve(DIR, '../public');
app.use(express.json());
app.use('/icons', express.static(resolve(DIR, '../public', 'icons')));
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*');
next();
@@ -134,6 +132,15 @@ app.get('/api/position', async (req, res) => {
res.json(filterFields(data, fields));
});
// ── Icon ──────────────────────────────────────────────────────────────────────
app.get('/api/icon/:icon', async (req, resp, next) => {
const { icon } = req.params;
if(!icon) return next(); // 404 fallback can handle missing icons
resp.contentType('image/png');
resp.sendFile(await fetchIcon(icon));
});
// ── Space ─────────────────────────────────────────────────────────────────────
// app.get('/api/aurora', async (req, res) => res.json(await Aurora.get()));

View File

@@ -131,7 +131,7 @@ export const spec = {
// Forecast
forecast_weathercode: { type: 'number', description: 'WMO weather code' },
forecast_weather_label: { type: 'string', description: 'Human readable weather label' },
forecast_weather_icon: { type: 'string', description: 'Local icon path e.g. /icons/01d.png' },
forecast_weather_icon: { type: 'string', description: 'Local icon path e.g. 01d' },
forecast_precipitation_mm: { type: 'number', description: 'Precipitation (mm)' },
forecast_precipitation_probability: { type: 'number', description: 'Precipitation probability (%)' },
forecast_snowfall_mm: { type: 'number', description: 'Snowfall (mm)' },