diff --git a/client/src/components/WeatherWidget.jsx b/client/src/components/WeatherWidget.jsx index c36e900a..bdde14de 100644 --- a/client/src/components/WeatherWidget.jsx +++ b/client/src/components/WeatherWidget.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import useDataRefresh from '../hooks/useDataRefresh.js'; import { Typography, @@ -23,6 +23,7 @@ import { API_BASE_URL } from '../utils/apiConfig.js'; import { getDeviceApiBase } from '../utils/deviceName.js'; import { formatTime, formatWeekdayShort } from '../utils/dateUtils.js'; import { isControlHidden } from '../utils/displayControls.js'; +import { coordinateBackfill, hasChosenLocation, parseTabConfigJson, tabSettingsReady } from '../utils/weatherTabSettings.js'; const DEFAULT_LOCATION_QUERY = '14818'; const VALID_LAYOUT_MODES = new Set(['auto', 'compact', 'medium', 'full']); @@ -79,6 +80,9 @@ const WeatherWidget = ({ const [coordinates, setCoordinates] = useState(null); const [layoutMode, setLayoutMode] = useState('auto'); const [settingsLoaded, setSettingsLoaded] = useState(false); + // Whether the place shown was saved by someone, not the default; only a + // chosen place is ever written back (utils/weatherTabSettings.js). + const locationChosen = useRef(false); const [settingsModalOpen, setSettingsModalOpen] = useState(false); // Control Limits (issue #166): the gear and the error-state button both open @@ -122,19 +126,6 @@ const WeatherWidget = ({ return 'F'; }; - const parseTabConfigJson = (configJson) => { - if (!configJson) return {}; - if (typeof configJson === 'object' && !Array.isArray(configJson)) return configJson; - if (typeof configJson !== 'string') return {}; - - try { - const parsed = JSON.parse(configJson); - return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; - } catch { - return {}; - } - }; - const readWeatherSettingsFromTabConfig = (configJson) => { const layoutMap = parseTabConfigJson(configJson); const weatherEntry = layoutMap.weather; @@ -153,6 +144,7 @@ const WeatherWidget = ({ layoutMode: VALID_LAYOUT_MODES.has(weatherEntry.layoutMode) ? weatherEntry.layoutMode : 'auto', coordinates, resolvedName: weatherEntry.resolvedName || '', + chosen: hasChosenLocation(weatherEntry), }; }; @@ -174,6 +166,7 @@ const WeatherWidget = ({ layoutMode: VALID_LAYOUT_MODES.has(override.layoutMode) ? override.layoutMode : 'auto', coordinates: isValidCoordinates(override) ? { lat: override.lat, lon: override.lon } : null, resolvedName: override.resolvedName || '', + chosen: hasChosenLocation(override), }; } @@ -363,6 +356,7 @@ const WeatherWidget = ({ setTempUnit(savedTempUnit); setCoordinates(savedCoordinates); setLayoutMode(savedLayoutMode); + locationChosen.current = !!resolvedSettings?.chosen; setDraftLocationQuery(savedLocationQuery); setDraftTempUnit(savedTempUnit); setDraftLayoutMode(savedLayoutMode); @@ -401,11 +395,15 @@ const WeatherWidget = ({ }); }; + // Until the tabs load, "no place saved" and "not loaded yet" look the + // same; wait rather than fall back to the default and fetch it. + const tabsReady = tabSettingsReady(activeTabConfigJson, allTabConfigs); useEffect(() => { + if (!tabsReady) return; const tabSettings = getEffectiveWeatherSettingsForTab(activeTab, activeTabConfigJson); setSettingsLoaded(false); applyResolvedTabSettings(tabSettings); - }, [activeTab, activeTabConfigJson, refreshNonce]); + }, [activeTab, activeTabConfigJson, refreshNonce, tabsReady]); // Demo mode needs no special case any more: the server picks the demo // provider behind GET /api/weather, so the widget takes the same path it @@ -436,6 +434,7 @@ const WeatherWidget = ({ tempUnit, layoutMode: layoutMode || 'auto', coordinates: isValidCoordinates(coordinates) ? coordinates : null, + chosen: locationChosen.current, }); } @@ -452,6 +451,7 @@ const WeatherWidget = ({ tempUnit: weatherSettings.tempUnit, layoutMode: weatherSettings.layoutMode || 'auto', coordinates: isValidCoordinates(weatherSettings.coordinates) ? weatherSettings.coordinates : null, + chosen: weatherSettings.chosen, }); } @@ -471,19 +471,9 @@ const WeatherWidget = ({ targetCoordinates: target.coordinates, }); - if ( - Number.isFinite(target.tabNumber) - && !isValidCoordinates(target.coordinates) - && isValidCoordinates(payload?.coordinates) - ) { - await saveInstanceSettingsToTab(target.tabNumber, { - locationQuery: target.locationQuery, - tempUnit: target.tempUnit, - layoutMode: target.layoutMode || 'auto', - lat: payload.coordinates.lat, - lon: payload.coordinates.lon, - ...(payload.resolvedName ? { resolvedName: payload.resolvedName } : {}), - }); + const backfill = coordinateBackfill(target, payload); + if (Number.isFinite(target.tabNumber) && backfill) { + await saveInstanceSettingsToTab(target.tabNumber, backfill); } } catch { // Keep prefetch failures non-blocking. @@ -529,8 +519,10 @@ const WeatherWidget = ({ applyWeatherPayloadToState(payload); if (isValidCoordinates(payload.coordinates)) { setCoordinates(payload.coordinates); - - // Backfill coordinates for existing tabs that were saved before coordinate persistence. + } + // Backfill coordinates for tabs saved before coordinates were kept, but + // only for a place someone chose: never write the default back. + if (isValidCoordinates(payload.coordinates) && locationChosen.current) { void persistActiveTabSettingsIfChanged({ nextLocationQuery: locationQuery, nextTempUnit: tempUnit, @@ -609,6 +601,7 @@ const WeatherWidget = ({ setTempUnit(normalizedTempUnit); setCoordinates(resolvedCoordinates); setLayoutMode(normalizedLayoutMode); + locationChosen.current = true; if (shouldRefreshForDataChange) { setShouldFetchNow(true); diff --git a/client/src/themes/engine/layers/flyby.jsx b/client/src/themes/engine/layers/flyby.jsx index 0c741083..ae8c13ae 100644 --- a/client/src/themes/engine/layers/flyby.jsx +++ b/client/src/themes/engine/layers/flyby.jsx @@ -2,7 +2,7 @@ import React, { useMemo, useState } from 'react'; import { Box } from '@mui/material'; import { keyframes } from '@emotion/react'; import { cross, draw, forMode, pick, pickWeighted } from '../motion.js'; -import { pathKeyframes, planPath } from '../paths.js'; +import { pathKeyframes, planOrbitSystem, planPath } from '../paths.js'; // Now and then, one picture crosses the screen: a ship, a passing whale, a // drifting astronaut. Each pass picks a picture (weighted), a side to enter @@ -14,12 +14,14 @@ import { pathKeyframes, planPath } from '../paths.js'; // (paths.js): a slice of a long parabola, or a meander that faces where it // goes and may turn around. `count` keeps several in flight at once, each on // its own loop, and `begin: underway` puts the first of each partway along -// its path when the page loads, so the scene does not start empty. +// its path when the page loads, so the scene does not start empty. With +// `path: orbit`, every picture in the layer circles one focus, the same way +// round, each on its own ellipse. const screenAspect = () => (typeof window !== 'undefined' && window.innerHeight ? window.innerWidth / window.innerHeight : 16 / 9); -function nextPass(random, options, mode, first = false) { +function nextPass(random, options, mode, first = false, system = null) { const picture = pickWeighted(random, options.pictures); const height = draw(random, picture.height ?? options.height ?? [5, 9]); const width = height * (picture.aspect ?? 1); @@ -42,8 +44,8 @@ function nextPass(random, options, mode, first = false) { // Underway: already some way along, with no wait; otherwise the pause first. pause: underway ? (-(0.15 + random() * 0.7) * seconds).toFixed(1) : draw(random, options.every ?? [60, 180]).toFixed(1), }; - if (options.path === 'arc' || options.path === 'wander') { - const { frames } = planPath(random, options, { width }, screenAspect()); + if (options.path === 'arc' || options.path === 'wander' || options.path === 'orbit') { + const { frames } = planPath(random, options, { width }, screenAspect(), system); return { id: random(), src: forMode(picture.src, mode), @@ -77,8 +79,8 @@ function nextPass(random, options, mode, first = false) { const prefersReducedMotion = () => typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; -function Traveler({ options, assets, random, mode }) { - const [pass, setPass] = useState(() => nextPass(random, options, mode, true)); +function Traveler({ options, assets, random, mode, system }) { + const [pass, setPass] = useState(() => nextPass(random, options, mode, true, system)); const travel = useMemo(() => (pass.frames ? keyframes`${pass.frames}` : null), [pass.frames]); const url = assets[pass.src]; if (!url) return null; @@ -96,7 +98,7 @@ function Traveler({ options, assets, random, mode }) { return ( setPass(nextPass(random, options, mode))} + onAnimationEnd={() => setPass(nextPass(random, options, mode, false, system))} sx={{ position: 'absolute', left: 0, @@ -137,9 +139,11 @@ function Traveler({ options, assets, random, mode }) { function Flyby({ options, assets, random, mode }) { const [still] = useState(prefersReducedMotion); const [count] = useState(() => Math.max(1, draw(random, options.count ?? 1, { integer: true }))); + // One focus and one way round for the whole layer, so the planets keep company. + const [system] = useState(() => (options.path === 'orbit' ? planOrbitSystem(random, options) : null)); if (still) return null; return Array.from({ length: count }, (_, i) => ( - + )); } diff --git a/client/src/themes/engine/paths.js b/client/src/themes/engine/paths.js index 770d831c..eb4c41c0 100644 --- a/client/src/themes/engine/paths.js +++ b/client/src/themes/engine/paths.js @@ -11,6 +11,9 @@ // off screen, so the visible part is a gentle curve (planets) // wander a meander up and down (fish); with `turn`, some swim partway in, // turn around and leave the way they came +// orbit a piece of an ellipse around a focus the whole layer shares, all +// going the same way round (planets): each picture its own ellipse, +// quicker nearer the focus, as Kepler has it import { draw } from './motion.js'; @@ -57,15 +60,72 @@ function distanceFn(random, options) { return (t) => raw(t) / end; } +/** + * What every orbit in a layer shares, drawn once per page load: the focus + * (x in vw, y in vh; it belongs off screen) and the way round, 1 clockwise + * on screen and -1 counterclockwise. + */ +export function planOrbitSystem(random, options) { + const focusX = draw(random, options.focusX ?? [-40, 140]); + const focusY = draw(random, options.focusY ?? [150, 260]); + const way = options.direction === 'clockwise' ? 1 + : options.direction === 'counterclockwise' ? -1 + : sign(random); + return { focusX, focusY, way }; +} + +const ORBIT_STEP = 0.002; // radians, when finding where an orbit leaves the screen + +/** One pass along an orbit: enter off screen, cross within `span`, leave off screen. */ +function planOrbit(random, options, picture, aspect, system) { + // Work in vh on both axes so the ellipse is not squashed by the screen's shape. + const fx = system.focusX * aspect; + const fy = system.focusY; + const [from, to] = options.span ?? [10, 70]; + // The point where this orbit crosses the screen, then the ellipse through it. + const cx = (30 + random() * 40) * aspect; + const cy = from + random() * (to - from); + const thetaC = Math.atan2(cy - fy, cx - fx); + const e = clamp(draw(random, options.eccentricity ?? [0, 0.3]), 0, 0.9); + const omega = random() * Math.PI * 2; + const p = Math.hypot(cx - fx, cy - fy) * (1 + e * Math.cos(thetaC - omega)); + const radius = (theta) => p / (1 + e * Math.cos(theta - omega)); + const at = (theta) => ({ X: fx + radius(theta) * Math.cos(theta), Y: fy + radius(theta) * Math.sin(theta) }); + const margin = (picture.width ?? 5) * 1.2; + const offScreen = ({ X, Y }) => X < -margin || X > 100 * aspect + margin || Y < -margin || Y > 100 + margin; + // Walk out from the crossing both ways until off screen (half an orbit at most). + const reach = (direction) => { + let d = 0; + while (d < Math.PI && !offScreen(at(thetaC + direction * d))) d += ORBIT_STEP; + return d; + }; + const start = thetaC - system.way * reach(-system.way); + const end = thetaC + system.way * reach(system.way); + + // Equal areas in equal times: time grows with r squared per step of angle. + const thetas = Array.from({ length: SAMPLES + 1 }, (_, i) => start + ((end - start) * i) / SAMPLES); + const weights = thetas.map((theta) => radius(theta) ** 2); + const elapsed = [0]; + for (let i = 1; i < thetas.length; i += 1) elapsed.push(elapsed[i - 1] + (weights[i - 1] + weights[i]) / 2); + const total = elapsed[elapsed.length - 1] || 1; + const frames = thetas.map((theta, i) => { + const { X, Y } = at(theta); + return { at: (elapsed[i] / total) * 100, x: X / aspect, y: Y, angle: 0, flip: 1 }; + }); + return { frames, rightward: frames[frames.length - 1].x > frames[0].x, turns: false }; +} + /** * One pass of a curved crossing. * * picture: { width } in vh. aspect: the screen's width over its height. + * system: for an orbit, the layer's shared planOrbitSystem(). * Returns { frames: [{ at, x, y, angle, flip }], rightward }, `at` in percent * of the pass, `flip` 1 facing right and -1 mirrored (it passes through 0 * during a turn-around). */ -export function planPath(random, options, picture, aspect = 16 / 9) { +export function planPath(random, options, picture, aspect = 16 / 9, system = null) { + if (options.path === 'orbit') return planOrbit(random, options, picture, aspect, system ?? planOrbitSystem(random, options)); const path = options.path === 'arc' ? 'arc' : 'wander'; const facing = options.facing ?? (path === 'arc' ? 'fixed' : 'path'); const pitch = draw(random, options.pitch ?? 15); diff --git a/client/src/themes/engine/paths.test.js b/client/src/themes/engine/paths.test.js index cd831b91..45c45047 100644 --- a/client/src/themes/engine/paths.test.js +++ b/client/src/themes/engine/paths.test.js @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { seededRandom } from './motion.js'; -import { pathKeyframes, planPath } from './paths.js'; +import { pathKeyframes, planOrbitSystem, planPath } from './paths.js'; const plans = (options, n = 40, width = 10) => Array.from({ length: n }, (_, i) => planPath(seededRandom(i + 1), options, { width }, 16 / 9)); const visible = (frames) => frames.filter((f) => f.x >= 0 && f.x <= 100); @@ -61,4 +61,80 @@ describe('curved crossings', () => { expect(css).toMatch(/^0\.00% \{ transform: translate\(-?[\d.]+vw, -?[\d.]+vh\) rotate\(-?[\d.]+deg\) scaleX\(-?[\d.]+\); \}/); expect(css).not.toMatch(/NaN|Infinity/); }); + + describe('orbits', () => { + const aspect = 16 / 9; + const options = { path: 'orbit', span: [10, 55], eccentricity: [0, 0.35] }; + // Several planets of one layer: one shared system, each its own pass. + const layer = (seed, extra = {}) => { + const random = seededRandom(seed); + const system = planOrbitSystem(random, { ...options, ...extra }); + return { system, passes: Array.from({ length: 6 }, () => planPath(random, { ...options, ...extra }, { width: 10 }, aspect, system)) }; + }; + // Angle about the focus, in vh on both axes, as the planner works. + const angleAbout = (system, f) => Math.atan2(f.y - system.focusY, f.x * aspect - system.focusX * aspect); + + it('every pass enters and leaves off screen, crossing it within `span`', () => { + for (let seed = 1; seed <= 30; seed += 1) { + for (const { frames } of layer(seed).passes) { + const off = (f) => f.x < 0 || f.x > 100 || f.y < 0 || f.y > 100; + expect(off(frames[0])).toBe(true); + expect(off(frames[frames.length - 1])).toBe(true); + expect(frames.some((f) => f.x > 25 && f.x < 75 && f.y >= 10 - 1e-6 && f.y <= 55 + 1e-6)).toBe(true); + frames.forEach((f) => { expect(f.angle).toBe(0); expect(f.flip).toBe(1); }); + } + } + }); + + it('all the planets of a layer go the same way round the one focus', () => { + const ways = new Set(); + for (let seed = 1; seed <= 30; seed += 1) { + const { system, passes } = layer(seed); + ways.add(system.way); + for (const { frames } of passes) { + // Unwrapped angle about the focus moves steadily in the layer's direction. + let previous = angleAbout(system, frames[0]); + for (const f of frames.slice(1)) { + let step = angleAbout(system, f) - previous; + if (step > Math.PI) step -= 2 * Math.PI; + if (step < -Math.PI) step += 2 * Math.PI; + expect(Math.sign(step)).toBe(system.way); + previous += step; + } + } + } + expect([...ways].sort()).toEqual([-1, 1]); // either, across page loads + expect(layer(3, { direction: 'clockwise' }).system.way).toBe(1); + expect(layer(3, { direction: 'counterclockwise' }).system.way).toBe(-1); + }); + + it('each planet is on its own ellipse with the focus at a focus', () => { + const { system, passes } = layer(7); + const fit = (frames) => { + // r(theta) = p / (1 + e cos(theta - omega)) means 1/r is linear in cos(theta) and sin(theta). + const rows = frames.map((f) => { const X = f.x * aspect - system.focusX * aspect; const Y = f.y - system.focusY; return [1, Math.cos(Math.atan2(Y, X)), Math.sin(Math.atan2(Y, X)), 1 / Math.hypot(X, Y)]; }); + const [a, b, c] = [0, 1, 2].map((k) => rows.reduce((sum, r) => sum + r[k] * r[3], 0)); + return rows.every((r) => Number.isFinite(r[3])) && [a, b, c]; + }; + const shapes = passes.map(({ frames }) => JSON.stringify(fit(frames).map((n) => n.toFixed(3)))); + expect(new Set(shapes).size).toBe(passes.length); + }); + + it('moves quicker nearer the focus (equal areas in equal times)', () => { + const { system, passes } = layer(11, { eccentricity: [0.6, 0.6] }); + for (const { frames } of passes) { + const r = (f) => Math.hypot(f.x * aspect - system.focusX * aspect, f.y - system.focusY); + // Angular speed times r squared is the same all along. + const swept = frames.slice(1).map((f, i) => { + let step = angleAbout(system, f) - angleAbout(system, frames[i]); + if (step > Math.PI) step -= 2 * Math.PI; + if (step < -Math.PI) step += 2 * Math.PI; + const rr = (r(f) + r(frames[i])) / 2; + return (Math.abs(step) * rr * rr) / (f.at - frames[i].at); + }); + const mean = swept.reduce((a, b) => a + b, 0) / swept.length; + swept.forEach((v) => expect(Math.abs(v - mean) / mean).toBeLessThan(0.02)); + } + }); + }); }); diff --git a/client/src/themes/engine/schemas.js b/client/src/themes/engine/schemas.js index b0209274..ae1c6275 100644 --- a/client/src/themes/engine/schemas.js +++ b/client/src/themes/engine/schemas.js @@ -98,7 +98,7 @@ export const LAYER_SCHEMAS = { spin: range(0, 1440), opacity: num(0, 1), // Curved crossings (manifest version 3); see paths.js. - path: { type: 'enum', values: ['line', 'arc', 'wander'] }, + path: { type: 'enum', values: ['line', 'arc', 'wander', 'orbit'] }, bend: range(0, 60), wander: range(0, 30), facing: { type: 'enum', values: ['path', 'fixed'] }, @@ -109,6 +109,11 @@ export const LAYER_SCHEMAS = { begin: { type: 'enum', values: ['waiting', 'underway'] }, hue: range(0, 360), angle: range(0, 90), + // Orbits (manifest version 5): the shared focus, how oval, which way round. + focusX: range(-300, 400), + focusY: range(-300, 400), + eccentricity: range(0, 0.9), + direction: { type: 'enum', values: ['either', 'clockwise', 'counterclockwise'] }, }, blobs: { colors: { type: 'colors', required: true }, @@ -132,6 +137,9 @@ export const LAYER_NAMES = Object.keys(LAYER_SCHEMAS); /** The flyby options manifest version 3 added: curved crossings, several at once. */ export const FLYBY_CURVE_OPTIONS = ['path', 'bend', 'wander', 'facing', 'pitch', 'glide', 'turn', 'count', 'begin', 'hue', 'angle']; +/** The flyby options manifest version 5 added: orbits around a shared focus. */ +export const FLYBY_ORBIT_OPTIONS = ['focusX', 'focusY', 'eccentricity', 'direction']; + // A theme's own confetti (chore and prize celebrations): its colors, which of // the standard shapes, and pictures from its folder mixed in. `pictures` // heights are in px; `mix` is the share of pieces that are pictures. diff --git a/client/src/utils/ornaments.test.js b/client/src/utils/ornaments.test.js index 7b1de041..bc182605 100644 --- a/client/src/utils/ornaments.test.js +++ b/client/src/utils/ornaments.test.js @@ -87,12 +87,23 @@ describe('manifest version', () => { expect(validateThemePackage({ ...base, manifestVersion: 2, ambience: [flyby] }, { assets: files })).toEqual(['curved flybys need manifestVersion 3']); // A straight flyby is still version 1. expect(validateThemePackage({ ...base, manifestVersion: 1, ambience: [{ layer: 'flyby', pictures: [{ src: 'assets/ring.svg' }], tilt: [0, 5] }] }, { assets: files })).toEqual([]); - expect(validateThemePackage({ ...base, manifestVersion: 3, ambience: [{ ...flyby, path: 'zigzag' }] }, { assets: files })[0]).toMatch(/path must be one of line, arc, wander/); + expect(validateThemePackage({ ...base, manifestVersion: 3, ambience: [{ ...flyby, path: 'zigzag' }] }, { assets: files })[0]).toMatch(/path must be one of line, arc, wander, orbit/); const tilted = { layer: 'flyby', pictures: [{ src: 'assets/ring.svg' }], angle: [0, 35] }; expect(validateThemePackage({ ...base, manifestVersion: 3, ambience: [tilted] }, { assets: files })).toEqual([]); expect(validateThemePackage({ ...base, manifestVersion: 2, ambience: [tilted] }, { assets: files })).toEqual(['curved flybys need manifestVersion 3']); }); + it('needs version 5 for orbits', () => { + const orbit = { layer: 'flyby', pictures: [{ src: 'assets/ring.svg' }], path: 'orbit', eccentricity: [0, 0.3] }; + expect(validateThemePackage({ ...base, manifestVersion: 5, ambience: [orbit] }, { assets: files })).toEqual([]); + expect(validateThemePackage({ ...base, manifestVersion: 4, ambience: [orbit] }, { assets: files })).toEqual(['orbits need manifestVersion 5']); + // An orbit option on an arc still asks for 5: an older core would not know the option. + const arc = { layer: 'flyby', pictures: [{ src: 'assets/ring.svg' }], path: 'arc', focusY: [150, 200] }; + expect(validateThemePackage({ ...base, manifestVersion: 4, ambience: [arc] }, { assets: files })).toEqual(['orbits need manifestVersion 5']); + expect(validateThemePackage({ ...base, manifestVersion: 5, ambience: [{ ...orbit, eccentricity: [0, 1.5] }] }, { assets: files })[0]).toMatch(/eccentricity/); + expect(validateThemePackage({ ...base, manifestVersion: 5, ambience: [{ ...orbit, direction: 'sideways' }] }, { assets: files })[0]).toMatch(/direction must be one of/); + }); + it('needs version 3 for button tokens, and checks their values', () => { const buttons = { '--hg-button-bg': '#ff9900', '--hg-button-text': '#000000', '--hg-button-radius': '999px', '--hg-button-weight': '700', '--hg-button-quiet-border': '#9977aa' }; expect(validateThemePackage({ ...base, manifestVersion: 3, tokens: { dark: buttons } })).toEqual([]); diff --git a/client/src/utils/themes.js b/client/src/utils/themes.js index c2215af2..65fb7fcd 100644 --- a/client/src/utils/themes.js +++ b/client/src/utils/themes.js @@ -19,7 +19,7 @@ // // scenes that follow the weather (utils/weatherScenes.js) // } -import { FLYBY_CURVE_OPTIONS, validateAmbience, validateConfetti, validateOrnaments } from '../themes/engine/schemas.js'; +import { FLYBY_CURVE_OPTIONS, FLYBY_ORBIT_OPTIONS, validateAmbience, validateConfetti, validateOrnaments } from '../themes/engine/schemas.js'; import { WEATHER_SCENE_KEYS } from './weatherScenes.js'; // Each theme is a folder: themes//theme.json, with its own fonts/ and @@ -273,8 +273,8 @@ export function validateThemePackage(pkg, { assets } = {}) { // Each version adds things a theme may use: 2, ornaments, the meter roles // and clumped sprites; 3, the button roles and curved flyby crossings // (path, count, begin and the rest); 4, weather scenes and lightning - // flashes. A theme that uses them says so, so a core too old to draw - // them refuses it plainly. + // flashes; 5, orbits. A theme that uses them says so, so a core too old + // to draw them refuses it plainly. const tokenNames = ['all', ...MODES].flatMap((key) => Object.keys(pkg.tokens?.[key] || {})); const layers = allAmbience(pkg); const needs = (version, uses) => { @@ -295,6 +295,10 @@ export function validateThemePackage(pkg, { assets } = {}) { pkg.weather !== undefined && 'weather', layers.some((layer) => layer?.layer === 'flash') && 'flash', ]); + needs(5, [ + layers.some((layer) => layer?.layer === 'flyby' + && (layer.path === 'orbit' || FLYBY_ORBIT_OPTIONS.some((key) => layer[key] !== undefined))) && 'orbits', + ]); } if (typeof pkg.id !== 'string' || !/^[a-z0-9][a-z0-9-]{0,63}$/.test(pkg.id)) errors.push('id must be a lowercase slug (a-z, 0-9, hyphens, max 64)'); if (typeof pkg.name !== 'string' || !pkg.name.trim() || pkg.name.length > 60) errors.push('name is required'); @@ -342,10 +346,10 @@ export const DEFAULT_THEME_ID = 'classic'; * manifest (a new section, new tokens) raises it, so an older core refuses a * theme it cannot draw rather than half-applying it (theme-architecture.md * ยง5). 2: ornaments and the meter roles. 3: the button roles and curved - * flybys. 4: weather scenes and the flash layer. The server's MANIFEST_VERSION + * flybys. 4: weather scenes and the flash layer. 5: orbits. The server's MANIFEST_VERSION * (services/themeStore.js) matches; a server test holds them equal. */ -export const MANIFEST_VERSION = 4; +export const MANIFEST_VERSION = 5; /** The problem reported for a theme newer than this core; the admin page words it for people. */ export const NEEDS_NEWER_HOMEGLOW = 'needs a newer version of HomeGlow'; diff --git a/client/src/utils/themes.test.js b/client/src/utils/themes.test.js index 916949a0..d2196456 100644 --- a/client/src/utils/themes.test.js +++ b/client/src/utils/themes.test.js @@ -109,7 +109,7 @@ describe('validateThemePackage', () => { }); it('follows the plugin manifest rules for manifestVersion, author and version', () => { - expect(validateThemePackage({ id: 'probe', name: 'Probe' })).toEqual(['manifestVersion must be 1 to 4']); + expect(validateThemePackage({ id: 'probe', name: 'Probe' })).toEqual(['manifestVersion must be 1 to 5']); expect(validateThemePackage({ ...base, author: 'Jane Diver', version: '2.1.0' })).toEqual([]); expect(validateThemePackage({ ...base, author: 'x'.repeat(81) })).toHaveLength(1); expect(validateThemePackage({ ...base, author: { name: 'Jane' } })).toHaveLength(1); diff --git a/client/src/utils/weatherTabSettings.js b/client/src/utils/weatherTabSettings.js new file mode 100644 index 00000000..604a559c --- /dev/null +++ b/client/src/utils/weatherTabSettings.js @@ -0,0 +1,55 @@ +// What the weather widget may do with a tab's saved settings. +// +// The widget shows a default place until a place is saved. Two rules keep +// that default from ever replacing a saved place: +// - nothing happens until the tabs have loaded: before then the widget +// cannot tell "no place saved" from "not loaded yet"; +// - only a place someone chose is written back. The widget saves a place's +// coordinates once it has looked them up, and doing that for the default +// used to overwrite a saved place whenever the tabs loaded slowly. + +/** The tab's layout map from its config_json, or {} when there is none to read. */ +export function parseTabConfigJson(configJson) { + if (!configJson) return {}; + if (typeof configJson === 'object' && !Array.isArray(configJson)) return configJson; + if (typeof configJson !== 'string') return {}; + try { + const parsed = JSON.parse(configJson); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; + } catch { + return {}; + } +} + +/** + * Whether the tabs have loaded, so settings can be read and acted on. The + * active tab's config is there once they have; a widget given every tab's + * config (the background prefetch) can also tell from that list. + */ +export function tabSettingsReady(activeTabConfigJson, allTabConfigs = []) { + return activeTabConfigJson != null || (Array.isArray(allTabConfigs) && allTabConfigs.length > 0); +} + +/** Whether a saved weather entry names a place, rather than leaving the default. */ +export function hasChosenLocation(entry) { + return !!entry && String(entry.locationQuery || entry.zipCode || '').trim() !== ''; +} + +/** + * The settings to save after looking up a place's coordinates, or null when + * nothing may be saved: the place was not chosen, or it already has them. + */ +export function coordinateBackfill(target, payload) { + if (!target?.chosen || target.coordinates) return null; + const lat = payload?.coordinates?.lat; + const lon = payload?.coordinates?.lon; + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; + return { + locationQuery: target.locationQuery, + tempUnit: target.tempUnit, + layoutMode: target.layoutMode || 'auto', + lat, + lon, + ...(payload.resolvedName ? { resolvedName: payload.resolvedName } : {}), + }; +} diff --git a/client/src/utils/weatherTabSettings.test.js b/client/src/utils/weatherTabSettings.test.js new file mode 100644 index 00000000..7fffe7c0 --- /dev/null +++ b/client/src/utils/weatherTabSettings.test.js @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { coordinateBackfill, hasChosenLocation, parseTabConfigJson, tabSettingsReady } from './weatherTabSettings.js'; + +describe('weather tab settings', () => { + it('waits for the tabs: no config and no tab list means not loaded yet', () => { + expect(tabSettingsReady(null, [])).toBe(false); + expect(tabSettingsReady(null, undefined)).toBe(false); + expect(tabSettingsReady('{"weather":{}}', [])).toBe(true); + // The background prefetch can be on a tab with no config of its own. + expect(tabSettingsReady(null, [{ number: 1, config_json: '{}' }])).toBe(true); + }); + + it('tells a chosen place from the default', () => { + expect(hasChosenLocation({ locationQuery: '94040' })).toBe(true); + expect(hasChosenLocation({ zipCode: '94040' })).toBe(true); // the older field + expect(hasChosenLocation({ locationQuery: ' ' })).toBe(false); + expect(hasChosenLocation({ layout_x: 8, layout_y: 0 })).toBe(false); // placed, never configured + expect(hasChosenLocation(null)).toBe(false); + }); + + it('saves looked-up coordinates only for a chosen place without them', () => { + const payload = { coordinates: { lat: 37.3855, lon: -122.088 }, resolvedName: 'Mountain View' }; + const chosen = { locationQuery: '94040', tempUnit: 'F', layoutMode: 'auto', coordinates: null, chosen: true }; + expect(coordinateBackfill(chosen, payload)).toEqual({ + locationQuery: '94040', tempUnit: 'F', layoutMode: 'auto', lat: 37.3855, lon: -122.088, resolvedName: 'Mountain View', + }); + // The bug: the default, shown before the tabs loaded, was written over a saved place. + expect(coordinateBackfill({ ...chosen, locationQuery: '14818', chosen: false }, payload)).toBeNull(); + expect(coordinateBackfill({ ...chosen, coordinates: { lat: 1, lon: 2 } }, payload)).toBeNull(); + expect(coordinateBackfill(chosen, { coordinates: null })).toBeNull(); + }); + + it('reads a tab layout map from a string or an object, and nothing from junk', () => { + expect(parseTabConfigJson('{"weather":{"locationQuery":"94040"}}')).toEqual({ weather: { locationQuery: '94040' } }); + expect(parseTabConfigJson({ weather: {} })).toEqual({ weather: {} }); + expect(parseTabConfigJson('not json')).toEqual({}); + expect(parseTabConfigJson('[1]')).toEqual({}); + expect(parseTabConfigJson(null)).toEqual({}); + }); +}); diff --git a/docs/architecture/theme-architecture.md b/docs/architecture/theme-architecture.md index a6ab676f..c8c91e2b 100644 --- a/docs/architecture/theme-architecture.md +++ b/docs/architecture/theme-architecture.md @@ -276,6 +276,8 @@ Version history: theme's), and the `flash` layer for lightning (issue #247). `resolveTheme` takes the scene to show; `app.jsx` picks it from the condition at the appearance location (`utils/weatherScenes.js`, `utils/useWeatherCondition.js`). +- **5:** orbits (`path: orbit` with `focusX`, `focusY`, `eccentricity` and + `direction`): every picture in a layer circles one focus, the same way round. ## 6. Implementation order diff --git a/docs/guides/theme-development.md b/docs/guides/theme-development.md index 32fcb185..8a2e188d 100644 --- a/docs/guides/theme-development.md +++ b/docs/guides/theme-development.md @@ -48,7 +48,7 @@ folder name must match the manifest's `id`. | Field | Required | Meaning | | --- | --- | --- | -| `manifestVersion` | yes | `1`; `2` for a theme that uses `ornaments`, the meter tokens or `clumps`; `3` for one that uses the button tokens or curved flybys; `4` for one with `weather` scenes or a `flash` layer. A HomeGlow older than the version refuses the theme. | +| `manifestVersion` | yes | `1`; `2` for a theme that uses `ornaments`, the meter tokens or `clumps`; `3` for one that uses the button tokens or curved flybys; `4` for one with `weather` scenes or a `flash` layer; `5` for one that uses orbits. A HomeGlow older than the version refuses the theme. | | `id` | yes | Lowercase slug (a-z, 0-9, hyphens), the same as the folder name. | | `name` | yes | Shown in the theme picker. | | `version`, `author`, `description` | | As in a plugin manifest. The author shows as "by ...". | @@ -146,8 +146,19 @@ A `flyby` can follow a curve instead of a straight line (manifest version 3): | `hue` | Degrees of hue shift, drawn per picture, to vary colors | | `angle` | A fixed tilt for the whole crossing, in degrees, either way: a ringed planet's ring at a slant | -Starship's planets arc slowly across the sky. A `wander` path with `turn` suits -fish: they face where they swim and sometimes turn back. +A flyby can also orbit (manifest version 5). With `path: orbit`, every picture +in the layer circles one focus, picked once per page load, and they all go the +same way round. Each gets its own ellipse through a point within `span`, and +moves quicker nearer the focus. + +| Option | What it does | +| --- | --- | +| `focusX`, `focusY` | Where the shared focus may fall, in vw and vh. Keep it off screen; the default is below the screen, so orbits arch across the sky | +| `eccentricity` | 0 (a circle) to 0.9: how oval each orbit is | +| `direction` | `clockwise`, `counterclockwise` or `either` (the default: picked once per page load) | + +Starship's planets orbit slowly across the sky. A `wander` path with `turn` +suits fish: they face where they swim and sometimes turn back. A picture can animate itself: an SVG with its own CSS animation, or an animated WebP or PNG. The engine draws it like any other picture. Keep such loops small diff --git a/server/services/themeStore.js b/server/services/themeStore.js index 0c2c1ad3..42a25987 100644 --- a/server/services/themeStore.js +++ b/server/services/themeStore.js @@ -21,7 +21,7 @@ const THEME_ID = /^[a-z0-9][a-z0-9-]{0,63}$/; // The newest theme manifest this core understands (client/src/utils/themes.js, // MANIFEST_VERSION; a test holds them equal). Newer themes are refused at // install and left out of the store's list. -const MANIFEST_VERSION = 4; +const MANIFEST_VERSION = 5; // Classic is the stylesheet itself; no package can replace it. const RESERVED_IDS = new Set(['classic']); const MAX_FILES = 64;