diff --git a/src/app/components/nav/styles.css.ts b/src/app/components/nav/styles.css.ts index 5c3f37b0..5156ed41 100644 --- a/src/app/components/nav/styles.css.ts +++ b/src/app/components/nav/styles.css.ts @@ -53,6 +53,10 @@ export const NavCategoryHeader = style({ '&&': { paddingLeft: 0 }, }, '@media': { + // A phone's section header is as tall as its 44 px button, so it does not overlap the next row. + 'screen and (max-width: 750px)': { + selectors: { '&&': { minHeight: toRem(44), height: toRem(44) } }, + }, // Category labels sit on the scene like the rows do, and need a wash to be read (wide screens; // a phone's list is plain text). 'screen and (min-width: 751px)': { @@ -117,6 +121,8 @@ const NavItemBase = style({ [PHONE]: { backgroundColor: 'transparent', borderRadius: 0, + // The row is as tall as the 44 px tap target inside it (EstateNavigator.css.ts), so neighbours do not overlap. + minHeight: toRem(44), }, [WIDE]: { selectors: { diff --git a/src/app/features/call-status/CallControl.tsx b/src/app/features/call-status/CallControl.tsx index 1808be92..3cf38b1f 100644 --- a/src/app/features/call-status/CallControl.tsx +++ b/src/app/features/call-status/CallControl.tsx @@ -14,7 +14,7 @@ type MicrophoneButtonProps = { function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps) { const [micState, toggleMic] = useAsyncCallback(onToggle); const loading = micState.status === AsyncStatus.Loading; - + return ( callEmbed.control.toggleMicrophone(), [callEmbed]); + const handleMicrophoneToggle = useCallback( + () => callEmbed.control.toggleMicrophone(), + [callEmbed] + ); const handleVideoToggle = useCallback(() => callEmbed.control.toggleVideo(), [callEmbed]); const [hangupState, hangup] = useAsyncCallback( @@ -195,11 +198,7 @@ export function CallControl({ disabled={!callJoined} /> {!compact && } - + {!compact && ( callEmbed.control.toggleMicrophone(), [callEmbed]); + const handleMicrophoneToggle = useCallback( + () => callEmbed.control.toggleMicrophone(), + [callEmbed] + ); const handleVideoToggle = useCallback(() => callEmbed.control.toggleVideo(), [callEmbed]); const [hangupState, hangup] = useAsyncCallback( @@ -97,10 +100,7 @@ export function CallControls({ callEmbed }: CallControlsProps) { > - + callEmbed.control.toggleSound()} /> {!compact && } diff --git a/src/app/features/call/Controls.tsx b/src/app/features/call/Controls.tsx index 6aa94ff5..459904f6 100644 --- a/src/app/features/call/Controls.tsx +++ b/src/app/features/call/Controls.tsx @@ -18,7 +18,7 @@ type MicrophoneButtonProps = { export function MicrophoneButton({ enabled, onToggle }: MicrophoneButtonProps) { const [micState, toggleMic] = useAsyncCallback(onToggle); const loading = micState.status === AsyncStatus.Loading; - + return ( toggleMicrophone(), [toggleMicrophone]); const handleVideoToggle = useCallback(async () => toggleVideo(), [toggleVideo]); diff --git a/src/app/features/desk/DeskView.css.ts b/src/app/features/desk/DeskView.css.ts index 4c7d08a7..042f0644 100644 --- a/src/app/features/desk/DeskView.css.ts +++ b/src/app/features/desk/DeskView.css.ts @@ -30,13 +30,29 @@ export const Title = style({ export const Fold = style({ all: 'unset', cursor: 'pointer', - display: 'block', width: '100%', minHeight: 44, boxSizing: 'border-box', + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: config.space.S200, + // A folded row says it can be opened: a chevron at the end, turned down while the row is open. + selectors: { + '&::after': { + content: '"›"', + flexShrink: 0, + fontSize: '1.5em', + lineHeight: 1, + transition: 'transform 120ms', + }, + '&[aria-expanded=true]::after': { transform: 'rotate(90deg)' }, + }, }); -export const Quiet = style({ opacity: config.opacity.P300, margin: `${config.space.S100} 0` }); +// Quiet lines (age, who it waits on, helper text) were measured at 4.5 over the day sky and 3.9-4.0 at dusk and night +// with the theme's 0.75 (taps2/desk-REPORT.md); 0.9 clears 4.5 in all three. +export const Quiet = style({ opacity: 0.9, margin: `${config.space.S100} 0` }); export const Body = style({ whiteSpace: 'pre-wrap', margin: `${config.space.S200} 0` }); export const Options = style({ margin: 0, padding: 0, listStyle: 'none' }); export const Option = style({ padding: `${config.space.S100} 0` }); @@ -50,8 +66,9 @@ export const Note = style({ font: 'inherit', color: 'inherit', background: 'none', - border: `1px solid ${color.Background.ContainerLine}`, - opacity: config.opacity.Disabled, + // The note is disabled (slice 1). Its border is a UI boundary (3:1 wanted; the faint line under the 0.38 disabled + // opacity measured 2.0), so it is drawn dashed in the full ink and not faded. + border: '1px dashed currentColor', }); /** Sticky beside the rows on wide screens; a small fixed corner image on phones. */ @@ -97,3 +114,9 @@ export const BoothLine = style({ }, }, }); + +/** The recorded rulings sit on the dark ridge in every sky; they carry their own ground (scrimStyle). */ +export const Recorded = style({ + marginTop: config.space.S400, + padding: `${config.space.S200} ${config.space.S300}`, +}); diff --git a/src/app/features/desk/DeskView.test.tsx b/src/app/features/desk/DeskView.test.tsx index 9c60ed53..1bc7aa59 100644 --- a/src/app/features/desk/DeskView.test.tsx +++ b/src/app/features/desk/DeskView.test.tsx @@ -1,14 +1,19 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { MemoryRouter } from 'react-router-dom'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { ScreenSize, ScreenSizeProvider } from '../../hooks/useScreenSize'; +import { SkyInkContextProvider } from '../../hooks/useTheme'; +import { SCRIM_GROUND, SCRIM_INK } from '../scene/skyInk'; import { DeskEntry } from './DeskEntry'; import { DeskView, SIGN_IN_NOTE } from './DeskView'; import { ForkList } from './forkContract'; import { STATES } from './fixtures'; import { setDeskSource } from './source'; +// useTheme reads the stored settings when it loads. +vi.hoisted(() => vi.stubGlobal('localStorage', { getItem: () => null, setItem: () => undefined })); + const view = ( state: keyof typeof STATES | 'unpublished', size: ScreenSize, @@ -66,6 +71,19 @@ describe('DeskView', () => { expect(html).toContain('Question done (ruling:done-1)'); expect(html).not.toContain('Question shut'); }); + it('gives the recorded rulings their own ground and ink while a scene runs, none without one', () => { + const withScene = renderToStaticMarkup( + + + + + + ); + // renderToStaticMarkup writes the inline style as the browser would read it back + expect(withScene).toContain(SCRIM_GROUND.replace(/, /g, ', ')); + expect(withScene).toContain(`color:${SCRIM_INK}`); + expect(view('settled', ScreenSize.Desktop)).not.toContain('background-color'); + }); it('shows each outcome quietly, marks the counted answer, and renders a note only when sent', () => { const html = view('answered', ScreenSize.Desktop); expect(html).toContain( @@ -145,7 +163,15 @@ describe("a question's age", () => { expect(html('2026-11-11T11:40:00Z')).toContain('raised just now'); }); it('shows no age when the card carries none, or an instant that does not parse or is ahead of the clock', () => { - ['null', 'yesterday', '2027-01-01T00:00:00Z', '2026-11-06', 'Nov 6 2026', '0', '2026-02-30T00:00:00Z'].forEach((v) => { + [ + 'null', + 'yesterday', + '2027-01-01T00:00:00Z', + '2026-11-06', + 'Nov 6 2026', + '0', + '2026-02-30T00:00:00Z', + ].forEach((v) => { expect(html(v === 'null' ? null : v)).not.toContain('raised '); }); }); diff --git a/src/app/features/desk/DeskView.tsx b/src/app/features/desk/DeskView.tsx index f83dcd82..64727799 100644 --- a/src/app/features/desk/DeskView.tsx +++ b/src/app/features/desk/DeskView.tsx @@ -4,6 +4,8 @@ import { PoseAvatar } from '../../components/message/pose/PoseAvatar'; import { MINDS } from '../../components/message/pose/poses'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { AnswerRow, Desk, buildDesk, DeskRow } from './buildDesk'; +import { useSkyInk } from '../../hooks/useTheme'; +import { scrimStyle } from '../scene/skyInk'; import { ForkList } from './forkContract'; import { deskMood } from './deskMood'; import { raisedAge } from './age'; @@ -144,6 +146,7 @@ type DeskViewProps = { /** One Mind's desk, read-only: a stream of rows, the recorded rulings, and the booth Persona. */ export function DeskView({ mind, label, list, unverified, now = Date.now() }: DeskViewProps) { const desk = buildDesk(list, unverified); + const skyInk = useSkyInk(); // Slice 1 has no session, so nothing on the desk is the viewer's answer yet. const mood = deskMood(desk.counts.open, 0); return ( @@ -166,7 +169,7 @@ export function DeskView({ mind, label, list, unverified, now = Date.now() }: De )} {desk.recorded.length > 0 && ( - <> +

Recorded as rulings

    {desk.recorded.map((r) => ( @@ -175,7 +178,7 @@ export function DeskView({ mind, label, list, unverified, now = Date.now() }: De ))}
- +
)} ); diff --git a/src/app/features/dings/manifest.test.ts b/src/app/features/dings/manifest.test.ts index 0001e685..722481b1 100644 --- a/src/app/features/dings/manifest.test.ts +++ b/src/app/features/dings/manifest.test.ts @@ -109,7 +109,10 @@ describe('v4 manifest against the parser', () => { it("every voice file is Aqua's v4 render (sha256 prefix)", () => { const hash = (file: string) => - createHash('sha256').update(readFileSync(join(DINGS, file))).digest('hex').slice(0, 12); + createHash('sha256') + .update(readFileSync(join(DINGS, file))) + .digest('hex') + .slice(0, 12); expect(Object.keys(V4_SHA).length).toBe(VOICES.length * MOTIFS.length); Object.entries(V4_SHA).forEach(([k, want]) => expect(hash(`senders/${k}.ogg`)).toBe(want)); }); diff --git a/src/app/features/lobby/LobbyHero.tsx b/src/app/features/lobby/LobbyHero.tsx index 1cd0b8e2..22774476 100644 --- a/src/app/features/lobby/LobbyHero.tsx +++ b/src/app/features/lobby/LobbyHero.tsx @@ -22,7 +22,9 @@ export function LobbyHero() { const name = useRoomName(space); const topic = useRoomTopic(space); const avatarMxc = useRoomAvatar(space); - const avatarUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined : undefined; + const avatarUrl = avatarMxc + ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined + : undefined; return ( {line} diff --git a/src/app/features/room/RoomViewHeader.css.ts b/src/app/features/room/RoomViewHeader.css.ts index cff21a99..e342c80c 100644 --- a/src/app/features/room/RoomViewHeader.css.ts +++ b/src/app/features/room/RoomViewHeader.css.ts @@ -2,15 +2,14 @@ import { style } from '@vanilla-extract/css'; import { config } from 'folds'; import { MOBILE_BREAKPOINT } from '../../hooks/useScreenSize'; -// Phone tap target: the topic is an 18 px line under the room name; its button grows 9 px up and 17 px down -// (44 px to tap; more below because the name is above) and the negative margin keeps the layout where it was. +// Phone tap target: the topic is an 18 px line directly under the room name (no gap), so its button grows only +// downward, 26 px (44 px to tap) and the negative margin keeps the layout where it was: growing up would take taps +// from the name line. export const HeaderTopic = style({ '@media': { [`screen and (max-width: ${MOBILE_BREAKPOINT}px)`]: { - paddingTop: 9, - paddingBottom: 17, - marginTop: -9, - marginBottom: -17, + paddingBottom: 26, + marginBottom: -26, }, }, ':hover': { diff --git a/src/app/features/scene/SkyStateControl.tsx b/src/app/features/scene/SkyStateControl.tsx index 30efaa1c..a4313a8f 100644 --- a/src/app/features/scene/SkyStateControl.tsx +++ b/src/app/features/scene/SkyStateControl.tsx @@ -15,7 +15,12 @@ import FocusTrap from 'focus-trap-react'; import { stopPropagation } from '../../utils/keyboard'; import { useSkyState } from '../../hooks/useSkyState'; import { SKY_STATES, setSkyState, type SkyState } from './skyState'; -import { HORIZON_CREDIT, WEATHER_CREDIT, WEATHER_CREDIT_URL, WEATHER_DATASET } from './ridge/credits'; +import { + HORIZON_CREDIT, + WEATHER_CREDIT, + WEATHER_CREDIT_URL, + WEATHER_DATASET, +} from './ridge/credits'; import { useClientConfig } from '../../hooks/useClientConfig'; import { getSceneStyleOverride, @@ -118,7 +123,11 @@ export function SkyStateControl() { )} {backgroundScene && sceneStyle === 'ridge' && ( - + Data diff --git a/src/app/features/scene/creatures.test.ts b/src/app/features/scene/creatures.test.ts index 938c217f..00dd48ef 100644 --- a/src/app/features/scene/creatures.test.ts +++ b/src/app/features/scene/creatures.test.ts @@ -12,7 +12,9 @@ describe('creature scale', () => { it('draws nothing for a neutral creature, whatever sizeBucket it was sent', () => { expect(creatureScale({ neutral: true })).toBeNull(); - buckets.forEach((sizeBucket) => expect(creatureScale({ sizeBucket, neutral: true })).toBeNull()); + buckets.forEach((sizeBucket) => + expect(creatureScale({ sizeBucket, neutral: true })).toBeNull() + ); }); it('does not crash or default to medium when a non-neutral creature has no sizeBucket', () => { diff --git a/src/app/features/scene/creatures.ts b/src/app/features/scene/creatures.ts index a0b2bd29..d88ce4b5 100644 --- a/src/app/features/scene/creatures.ts +++ b/src/app/features/scene/creatures.ts @@ -33,5 +33,7 @@ export const BUCKET_SCALE: Readonly> = { /** The draw scale, or null when nothing is drawn: a neutral creature, or a bucket that is absent or unknown. */ export function creatureScale(c: Pick): number | null { if (c.neutral || c.sizeBucket === undefined) return null; - return Object.prototype.hasOwnProperty.call(BUCKET_SCALE, c.sizeBucket) ? BUCKET_SCALE[c.sizeBucket] : null; + return Object.prototype.hasOwnProperty.call(BUCKET_SCALE, c.sizeBucket) + ? BUCKET_SCALE[c.sizeBucket] + : null; } diff --git a/src/app/features/scene/cssModules.test.ts b/src/app/features/scene/cssModules.test.ts index 27fd4d4d..7205b822 100644 --- a/src/app/features/scene/cssModules.test.ts +++ b/src/app/features/scene/cssModules.test.ts @@ -13,6 +13,8 @@ describe('scene .css.ts files, compiled as the production build compiles them', it.each(FILES)('%s compiles with exports vanilla-extract accepts', async (file) => { const filePath = `${process.cwd()}/${file}`; const { source } = await compile({ filePath, cwd: process.cwd(), identOption: 'debug' }); - await expect(processVanillaFile({ source, filePath, identOption: 'debug' })).resolves.toBeTypeOf('string'); + await expect( + processVanillaFile({ source, filePath, identOption: 'debug' }) + ).resolves.toBeTypeOf('string'); }); }); diff --git a/src/app/features/scene/ridge/Ridge.test.ts b/src/app/features/scene/ridge/Ridge.test.ts index 0234e558..cb24f04b 100644 --- a/src/app/features/scene/ridge/Ridge.test.ts +++ b/src/app/features/scene/ridge/Ridge.test.ts @@ -818,8 +818,12 @@ describe('endless drift: chunks baked on demand and recycled', () => { }); describe('rain in the scene', () => { - const rainy = (w: number): SkyColours => ({ ...DAY, physical: makePhysicalSky('day', new Date('2026-06-21T10:40:00Z'), w) }); - const rainEls = (parent: any) => parent.children.filter((c: any) => c.tag === 'div' && c !== rigOf(parent)); + const rainy = (w: number): SkyColours => ({ + ...DAY, + physical: makePhysicalSky('day', new Date('2026-06-21T10:40:00Z'), w), + }); + const rainEls = (parent: any) => + parent.children.filter((c: any) => c.tag === 'div' && c !== rigOf(parent)); it('lays one falling tile per rain layer over the ridges when it rains, each slid by its own height on the compositor', () => { const { parent, canvas } = host(); diff --git a/src/app/features/scene/ridge/Ridge.ts b/src/app/features/scene/ridge/Ridge.ts index 8590a833..b0320b1b 100644 --- a/src/app/features/scene/ridge/Ridge.ts +++ b/src/app/features/scene/ridge/Ridge.ts @@ -6,7 +6,15 @@ import { skyColours, SkyColours, Srgb } from './skyColour'; import { paintSky as paintPhysicalSky } from './physicalSky'; import { activeAt, drawStars, drawTwinkle as paintTwinkle, makeStars, Star } from './stars'; import { bakeSlice, BakeParams, layerPaint } from './ridgeBake'; -import { LAYERS, LayerSpec, edgeBlur, HORIZON_TRACK_SHIFT, layerSize, scatterAlpha, transmittance } from './ridgeWorld'; +import { + LAYERS, + LayerSpec, + edgeBlur, + HORIZON_TRACK_SHIFT, + layerSize, + scatterAlpha, + transmittance, +} from './ridgeWorld'; import { hfovDeg } from './horizon'; import { viewFor, VFOV_DEG } from './skyDome'; import { drawRainTile, RAIN_TILES } from './rain'; @@ -471,7 +479,13 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { * The rain over everything: per tile, a div of the tile's texture (drawn once) taller than the viewport by one tile, slid down one * tile height per loop on the compositor. 'still' draws none (a settled still has no animation to carry it). */ - const startRain = (rain: number, colour: Srgb, w: number, h: number, scale: number): NonNullable => { + const startRain = ( + rain: number, + colour: Srgb, + w: number, + h: number, + scale: number + ): NonNullable => { const els: HTMLElement[] = []; const anims: (Animation | undefined)[] = []; RAIN_TILES.forEach((t, k) => { @@ -498,11 +512,14 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { parent.appendChild(el); els.push(el); anims.push( - el.animate?.([{ transform: 'translate3d(0,0,0)' }, { transform: `translate3d(0,${px}px,0)` }], { - duration: t.seconds * 1000, - iterations: Infinity, - easing: 'linear', - }) + el.animate?.( + [{ transform: 'translate3d(0,0,0)' }, { transform: `translate3d(0,${px}px,0)` }], + { + duration: t.seconds * 1000, + iterations: Infinity, + easing: 'linear', + } + ) ); }); return { els, anims }; @@ -606,7 +623,8 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { parent.appendChild(rig); layers.forEach((l) => startDrift(l, rotate)); built.sway = startSway(rig, w, h); - if (tier !== 'still' && sky.physical && sky.physical.weather.rain > 0) built.rain = startRain(sky.physical.weather.rain, sky.horizon, w, h, scale); + if (tier !== 'still' && sky.physical && sky.physical.weather.rain > 0) + built.rain = startRain(sky.physical.weather.rain, sky.horizon, w, h, scale); pump(); syncPlayState(); return true; @@ -640,7 +658,9 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { }; function syncPlayState() { - const anims = built ? [built.sway, ...built.layers.map((l) => l.anim), ...(built.rain?.anims ?? [])] : []; + const anims = built + ? [built.sway, ...built.layers.map((l) => l.anim), ...(built.rain?.anims ?? [])] + : []; anims.forEach((a) => (moving() ? a?.play() : a?.pause())); if (!built?.twinkle) return; if (moving()) { diff --git a/src/app/features/scene/ridge/credits.test.ts b/src/app/features/scene/ridge/credits.test.ts index 633a1b33..887cc2d4 100644 --- a/src/app/features/scene/ridge/credits.test.ts +++ b/src/app/features/scene/ridge/credits.test.ts @@ -4,7 +4,7 @@ import { HORIZON_ATTRIBUTION } from './horizon'; import { WEATHER_ATTRIBUTION } from './weatherFeed'; describe('the credits the Sky menu shows', () => { - it('are the sources\' own', () => { + it("are the sources' own", () => { expect(HORIZON_CREDIT).toBe(HORIZON_ATTRIBUTION); expect(WEATHER_CREDIT).toBe(WEATHER_ATTRIBUTION); }); diff --git a/src/app/features/scene/ridge/earthAtmosphere.ts b/src/app/features/scene/ridge/earthAtmosphere.ts index f5350244..cadddbc5 100644 --- a/src/app/features/scene/ridge/earthAtmosphere.ts +++ b/src/app/features/scene/ridge/earthAtmosphere.ts @@ -362,7 +362,8 @@ function bakeMultiScatter(mieScale: number, trLut: Lut2): Lut2 { const sx = Math.sqrt(Math.max(0, 1 - cosSun * cosSun)); const sz = cosSun; // Hillaire's PLANET_RADIUS_OFFSET: a ray that starts exactly on the ground is degenerate. - const height = PLANET_KM + RADIUS_OFFSET_KM + clamp(v, 0, 1) * (TOP_KM - PLANET_KM - RADIUS_OFFSET_KM); + const height = + PLANET_KM + RADIUS_OFFSET_KM + clamp(v, 0, 1) * (TOP_KM - PLANET_KM - RADIUS_OFFSET_KM); const sumMs = [0, 0, 0]; const sumL = [0, 0, 0]; for (let a = 0; a < dirs; a++) { @@ -410,7 +411,11 @@ function bakeMultiScatter(mieScale: number, trLut: Lut2): Lut2 { * Sky-view LUT axes (Hillaire's non-linear mapping, upper hemisphere only: nothing below the horizon is drawn). y: view zenith angle * from 0 to the horizon's, squared toward it; x: the azimuth from the sun, the cosine mapped `-(2 c^2 - 1)`. */ -function skyViewParamsToUv(viewCos: number, cosFromSun: number, horizonZenith: number): [number, number] { +function skyViewParamsToUv( + viewCos: number, + cosFromSun: number, + horizonZenith: number +): [number, number] { const zen = Math.acos(clamp(viewCos, -1, 1)); let coord = 1 - clamp(zen / horizonZenith, 0, 1); coord = Math.sqrt(coord); @@ -494,7 +499,14 @@ export function bakeAtmosphere(opts: AtmosphereOptions): Atmosphere { const horizonZenith = PI - beta; const transmittance = bakeTransmittance(mieScale); const multiScatter = bakeMultiScatter(mieScale, transmittance); - const skyView = bakeSkyView(sunCos, observerKm, mieScale, transmittance, multiScatter, horizonZenith); + const skyView = bakeSkyView( + sunCos, + observerKm, + mieScale, + transmittance, + multiScatter, + horizonZenith + ); const tmp = [0, 0, 0]; const trv = [0, 0, 0]; const atm: Atmosphere = { @@ -514,7 +526,11 @@ export function bakeAtmosphere(opts: AtmosphereOptions): Atmosphere { out[2] = trv[2]; }, radiance(viewCos, cosFromSun, out) { - const [x, y] = skyViewParamsToUv(Math.max(viewCos, Math.cos(horizonZenith)), cosFromSun, horizonZenith); + const [x, y] = skyViewParamsToUv( + Math.max(viewCos, Math.cos(horizonZenith)), + cosFromSun, + horizonZenith + ); sampleTexel(skyView, x * (skyView.w - 1), y * (skyView.h - 1), tmp); out[0] = tmp[0]; out[1] = tmp[1]; diff --git a/src/app/features/scene/ridge/horizon.test.ts b/src/app/features/scene/ridge/horizon.test.ts index 092d5835..0061bab1 100644 --- a/src/app/features/scene/ridge/horizon.test.ts +++ b/src/app/features/scene/ridge/horizon.test.ts @@ -21,7 +21,10 @@ describe('the baked horizon', () => { expect(horizonDistanceKm(305)).toBeGreaterThan(1); }); it('interpolates between samples and returns unit normals', () => { - expect(horizonElevation(100.25)).toBeCloseTo((bake.elevationDeg[200] + bake.elevationDeg[201]) / 2, 6); + expect(horizonElevation(100.25)).toBeCloseTo( + (bake.elevationDeg[200] + bake.elevationDeg[201]) / 2, + 6 + ); expect(Math.hypot(...horizonNormal(250))).toBeCloseTo(1, 6); }); }); diff --git a/src/app/features/scene/ridge/horizon.ts b/src/app/features/scene/ridge/horizon.ts index 82265a77..89d896bc 100644 --- a/src/app/features/scene/ridge/horizon.ts +++ b/src/app/features/scene/ridge/horizon.ts @@ -8,7 +8,7 @@ const N = bake.elevationDeg.length; /** Index pair and weight of an azimuth (degrees clockwise from north) on the baked ring. */ function at(azimuth: number): [number, number, number] { - const f = ((((azimuth / bake.stepDeg) % N) + N) % N); + const f = (((azimuth / bake.stepDeg) % N) + N) % N; const i = Math.floor(f); return [i, (i + 1) % N, f - i]; } diff --git a/src/app/features/scene/ridge/horizonLayer.test.ts b/src/app/features/scene/ridge/horizonLayer.test.ts index 8ed27e4c..b034d03c 100644 --- a/src/app/features/scene/ridge/horizonLayer.test.ts +++ b/src/app/features/scene/ridge/horizonLayer.test.ts @@ -25,7 +25,9 @@ describe('the horizon layer', () => { }); it('stays in 0..1 and covers a real range of shape, not a line', () => { - const v = Array.from({ length: 200 }, (_, i) => layerProfile(far, -0.06 + (i / 200) * 1.2, { azimuthDeg: 305, hfovDeg: view.hfovDeg })); + const v = Array.from({ length: 200 }, (_, i) => + layerProfile(far, -0.06 + (i / 200) * 1.2, { azimuthDeg: 305, hfovDeg: view.hfovDeg }) + ); expect(Math.min(...v)).toBeGreaterThanOrEqual(0); expect(Math.max(...v)).toBeLessThanOrEqual(1); expect(Math.max(...v) - Math.min(...v)).toBeGreaterThan(0.2); @@ -43,7 +45,7 @@ describe('the horizon layer', () => { }); describe('weather in the layers', () => { - it('mist multiplies the layers\' own, and clear air leaves it alone', () => { + it("mist multiplies the layers' own, and clear air leaves it alone", () => { const base = skyColours(); const day = new Date('2026-06-21T10:40:00Z'); const clear = layerPaint(LAYERS[1], { ...base, physical: makePhysicalSky('day', day, 0) }); diff --git a/src/app/features/scene/ridge/physicalSky.test.ts b/src/app/features/scene/ridge/physicalSky.test.ts index bb8c75a2..be12945e 100644 --- a/src/app/features/scene/ridge/physicalSky.test.ts +++ b/src/app/features/scene/ridge/physicalSky.test.ts @@ -10,7 +10,8 @@ const lin = (v: number): number => { const c = v / 255; return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; }; -const luma = (d: Uint8ClampedArray, i: number): number => 0.2126 * lin(d[i]) + 0.7152 * lin(d[i + 1]) + 0.0722 * lin(d[i + 2]); +const luma = (d: Uint8ClampedArray, i: number): number => + 0.2126 * lin(d[i]) + 0.7152 * lin(d[i + 1]) + 0.0722 * lin(d[i + 2]); const DAYS = ['2026-03-20', '2026-06-21', '2026-09-23', '2026-12-21']; const WEATHER = [0, 0.3, 0.55, 0.75, 1]; @@ -21,28 +22,31 @@ const VIEWPORTS: [number, number][] = [ [390, 844], ]; -describe.each(['day', 'dusk', 'night'] as FixedSkyState[])('the %s sky stays inside its backdrop bounds', (state) => { - it('over weather, season and viewport shape', () => { - const b = skyBackdrop(state); - let lo = 2; - let hi = -1; - for (const day of DAYS) { - for (const w of WEATHER) { - const sky = makePhysicalSky(state, new Date(`${day}T10:40:00Z`), w); - for (const [vw, vh] of VIEWPORTS) { - const px = paintSky(sky, vw, vh); - for (let i = 0; i < px.w * px.h * 4; i += 4) { - const l = luma(px.data, i); - lo = Math.min(lo, l); - hi = Math.max(hi, l); +describe.each(['day', 'dusk', 'night'] as FixedSkyState[])( + 'the %s sky stays inside its backdrop bounds', + (state) => { + it('over weather, season and viewport shape', () => { + const b = skyBackdrop(state); + let lo = 2; + let hi = -1; + for (const day of DAYS) { + for (const w of WEATHER) { + const sky = makePhysicalSky(state, new Date(`${day}T10:40:00Z`), w); + for (const [vw, vh] of VIEWPORTS) { + const px = paintSky(sky, vw, vh); + for (let i = 0; i < px.w * px.h * 4; i += 4) { + const l = luma(px.data, i); + lo = Math.min(lo, l); + hi = Math.max(hi, l); + } } } } - } - expect(lo).toBeGreaterThanOrEqual(b.darkest); - expect(hi).toBeLessThanOrEqual(b.lightest); - }); -}); + expect(lo).toBeGreaterThanOrEqual(b.darkest); + expect(hi).toBeLessThanOrEqual(b.lightest); + }); + } +); describe('weather in the sky', () => { it('overcast is cloud: less blue and less contrast than clear air at the same sun', () => { @@ -70,14 +74,21 @@ describe('weather in the sky', () => { describe('the sun disc', () => { it('keeps the display floor the dark link was fitted to, at every hour it is drawn', () => { - const lin = (c: number) => (c / 255 <= 0.04045 ? c / 255 / 12.92 : ((c / 255 + 0.055) / 1.055) ** 2.4); + const lin = (c: number) => + c / 255 <= 0.04045 ? c / 255 / 12.92 : ((c / 255 + 0.055) / 1.055) ** 2.4; let drawn = 0; for (const state of ['day', 'dusk'] as FixedSkyState[]) { for (let m = 0; m < 24 * 60; m += 10) { - const d = paintSky(makePhysicalSky(state, new Date(Date.UTC(2026, 10, 11, 0, m)), 0), 1280, 720).sunDisc; + const d = paintSky( + makePhysicalSky(state, new Date(Date.UTC(2026, 10, 11, 0, m)), 0), + 1280, + 720 + ).sunDisc; if (!d) continue; drawn += 1; - expect(0.2126 * lin(d.rgb[0]) + 0.7152 * lin(d.rgb[1]) + 0.0722 * lin(d.rgb[2])).toBeGreaterThanOrEqual(0.4744); + expect( + 0.2126 * lin(d.rgb[0]) + 0.7152 * lin(d.rgb[1]) + 0.0722 * lin(d.rgb[2]) + ).toBeGreaterThanOrEqual(0.4744); } } expect(drawn).toBeGreaterThan(0); diff --git a/src/app/features/scene/ridge/physicalSky.ts b/src/app/features/scene/ridge/physicalSky.ts index 48a10a16..0e034416 100644 --- a/src/app/features/scene/ridge/physicalSky.ts +++ b/src/app/features/scene/ridge/physicalSky.ts @@ -4,7 +4,15 @@ import type { FixedSkyState } from '../skyBand'; import { bakeAtmosphere } from './earthAtmosphere'; import type { Atmosphere } from './earthAtmosphere'; -import { domeSize, HORIZON_AT, renderDome, SUN_RADIUS, sunOnScreen, viewFor, VFOV_DEG } from './skyDome'; +import { + domeSize, + HORIZON_AT, + renderDome, + SUN_RADIUS, + sunOnScreen, + viewFor, + VFOV_DEG, +} from './skyDome'; import type { Dome, DomeView } from './skyDome'; import type { Srgb } from './skyColour'; import { display8, fitDisplay, holdBudget, MARGIN } from './toneMap'; @@ -45,7 +53,11 @@ export function rowMean(dome: Dome, display: Display, row: number): Srgb { return [Math.round(acc[0] / dome.w), Math.round(acc[1] / dome.w), Math.round(acc[2] / dome.w)]; } -export function makePhysicalSky(state: FixedSkyState, now: Date, weatherValue: number): PhysicalSky { +export function makePhysicalSky( + state: FixedSkyState, + now: Date, + weatherValue: number +): PhysicalSky { const t0 = clock(); const sun = sunForState(state, now); const weather = weatherOf(weatherValue); @@ -117,8 +129,16 @@ function sunDisc(p: PhysicalSky, view: DomeView): SkyPixels['sunDisc'] { const top = Math.max(...trans, 1e-9); const rgb = trans.map((t) => Math.round(255 * (t / top) ** 0.6)); // The disc sits on the sky, so it keeps the display floor the dark link was fitted to: mix toward white until it does. - const lin = (c: number): number => (c / 255 <= 0.04045 ? c / 255 / 12.92 : ((c / 255 + 0.055) / 1.055) ** 2.4); - const luma = (c: number[]): number => 0.2126 * lin(c[0]) + 0.7152 * lin(c[1]) + 0.0722 * lin(c[2]); - for (let m = 0; luma(rgb) < DISC_FLOOR && m < 40; m += 1) for (let i = 0; i < 3; i += 1) rgb[i] = Math.round(rgb[i] + (255 - rgb[i]) * 0.1); - return { ...at, rgb: rgb as unknown as Srgb, radius: (SUN_RADIUS * 180) / Math.PI / VFOV_DEG, alpha }; + const lin = (c: number): number => + c / 255 <= 0.04045 ? c / 255 / 12.92 : ((c / 255 + 0.055) / 1.055) ** 2.4; + const luma = (c: number[]): number => + 0.2126 * lin(c[0]) + 0.7152 * lin(c[1]) + 0.0722 * lin(c[2]); + for (let m = 0; luma(rgb) < DISC_FLOOR && m < 40; m += 1) + for (let i = 0; i < 3; i += 1) rgb[i] = Math.round(rgb[i] + (255 - rgb[i]) * 0.1); + return { + ...at, + rgb: rgb as unknown as Srgb, + radius: (SUN_RADIUS * 180) / Math.PI / VFOV_DEG, + alpha, + }; } diff --git a/src/app/features/scene/ridge/rain.test.ts b/src/app/features/scene/ridge/rain.test.ts index 6575ea20..80ce2289 100644 --- a/src/app/features/scene/ridge/rain.test.ts +++ b/src/app/features/scene/ridge/rain.test.ts @@ -4,7 +4,8 @@ import { drawRainTile, RAIN_TILES, streakCount } from './rain'; const recorder = () => { const calls: string[] = []; const ctx = new Proxy({} as Record, { - get: (t, k: string) => (k in t ? t[k] : (...a: unknown[]) => calls.push(`${k}(${a.join(',')})`)), + get: (t, k: string) => + k in t ? t[k] : (...a: unknown[]) => calls.push(`${k}(${a.join(',')})`), set: (t, k: string, v) => { t[k] = v; return true; @@ -27,6 +28,8 @@ describe('rain', () => { drawRainTile(a.ctx, RAIN_TILES[1], 1, [200, 210, 220], 1); drawRainTile(b.ctx, RAIN_TILES[1], 1, [200, 210, 220], 1); expect(a.calls).toEqual(b.calls); - expect(a.calls.filter((c) => c.startsWith('stroke'))).toHaveLength(streakCount(RAIN_TILES[1], 1) * 9); + expect(a.calls.filter((c) => c.startsWith('stroke'))).toHaveLength( + streakCount(RAIN_TILES[1], 1) * 9 + ); }); }); diff --git a/src/app/features/scene/ridge/rain.ts b/src/app/features/scene/ridge/rain.ts index 699130cf..2644ed96 100644 --- a/src/app/features/scene/ridge/rain.ts +++ b/src/app/features/scene/ridge/rain.ts @@ -27,10 +27,18 @@ export const RAIN_TILES: readonly RainTile[] = [ export const RAIN_SLANT = 0.18; /** Streaks to draw at a rain amount 0..1 (none at 0). */ -export const streakCount = (t: RainTile, rain: number): number => (rain <= 0 ? 0 : Math.max(4, Math.round(t.streaks * rain))); +export const streakCount = (t: RainTile, rain: number): number => + rain <= 0 ? 0 : Math.max(4, Math.round(t.streaks * rain)); /** Draw one tile. The streaks wrap across the tile's edges so the tile repeats without a seam. */ -export function drawRainTile(ctx: CanvasRenderingContext2D, t: RainTile, rain: number, colour: Srgb, scale: number, seed = 0): void { +export function drawRainTile( + ctx: CanvasRenderingContext2D, + t: RainTile, + rain: number, + colour: Srgb, + scale: number, + seed = 0 +): void { const n = streakCount(t, rain); ctx.clearRect(0, 0, t.size, t.size); ctx.lineCap = 'round'; diff --git a/src/app/features/scene/ridge/ridgeBake.ts b/src/app/features/scene/ridge/ridgeBake.ts index 72736d85..063c4e3e 100644 --- a/src/app/features/scene/ridge/ridgeBake.ts +++ b/src/app/features/scene/ridge/ridgeBake.ts @@ -3,7 +3,15 @@ // slope of the heightfield's crest line against the sun; the face under it takes a light-to-haze // gradient, erosion streaks and a lit rim on the crest. The depth treatment (colour, haze, contrast // by distance) is ridgeWorld's `layerColour`, `hazeColour` and `transmittance`, used here and not redone. -import { gullyAt, HorizonView, LayerSpec, layerProfile, hazeColour, layerColour, transmittance } from './ridgeWorld'; +import { + gullyAt, + HorizonView, + LayerSpec, + layerProfile, + hazeColour, + layerColour, + transmittance, +} from './ridgeWorld'; import type { SkyBand } from '../skyBand'; import { LUMA, SKY_BAND } from '../skyBand'; import type { SkyColours, Srgb } from './skyColour'; @@ -137,7 +145,10 @@ function crestRows(p: BakeParams, c0: number, c1: number): Float64Array { const amp = p.spec.relief * p.viewH; const lift = p.spec.base * p.viewH; for (let c = c0 - 1; c <= c1; c += 1) { - const top = Math.min(p.bandH, lift + amp * layerProfile(p.spec, (p.px0 + c) / p.period, p.horizonView)); + const top = Math.min( + p.bandH, + lift + amp * layerProfile(p.spec, (p.px0 + c) / p.period, p.horizonView) + ); out[c - c0 + 1] = p.bandH - top; } return out; diff --git a/src/app/features/scene/ridge/ridgeWorld.ts b/src/app/features/scene/ridge/ridgeWorld.ts index 6bd8e3b1..c6c745c3 100644 --- a/src/app/features/scene/ridge/ridgeWorld.ts +++ b/src/app/features/scene/ridge/ridgeWorld.ts @@ -47,7 +47,6 @@ export interface HorizonView { /** The static horizon layer's track sits this many viewport widths left of the screen, so the camera's sway never shows its edge. */ export const HORIZON_TRACK_SHIFT = 0.06; - /** Four layers: one function, four parameter rows. */ export const LAYERS: readonly LayerSpec[] = [ { @@ -225,7 +224,13 @@ export function layerSize(cssW: number, cssH: number, scale: number) { } /** Ridge-top y (canvas pixels, from the top of the layer's band) at world-x `t` (viewport widths). */ -export function ridgeY(spec: LayerSpec, t: number, bandH: number, viewH: number, view?: HorizonView): number { +export function ridgeY( + spec: LayerSpec, + t: number, + bandH: number, + viewH: number, + view?: HorizonView +): number { const topFromBottom = spec.base * viewH + spec.relief * viewH * layerProfile(spec, t, view); return bandH - Math.min(bandH, topFromBottom); } diff --git a/src/app/features/scene/ridge/toneMap.ts b/src/app/features/scene/ridge/toneMap.ts index b91a4d6d..44bc9dbe 100644 --- a/src/app/features/scene/ridge/toneMap.ts +++ b/src/app/features/scene/ridge/toneMap.ts @@ -20,7 +20,8 @@ const OUT: readonly Rgb[] = [ [-0.10208, 1.10813, -0.00605], [-0.00327, -0.07276, 1.07602], ]; -const fit = (v: number): number => (v * (v + 0.0245786) - 0.000090537) / (v * (0.983729 * v + 0.432951) + 0.238081); +const fit = (v: number): number => + (v * (v + 0.0245786) - 0.000090537) / (v * (0.983729 * v + 0.432951) + 0.238081); const encode = (l: number): number => { const c = Math.min(1, Math.max(0, l)); return 255 * (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055); @@ -32,7 +33,8 @@ export function acesLinear(r: number, g: number, b: number, exposure: number, ou const y = g * exposure; const z = b * exposure; const p = [0, 1, 2].map((i) => fit(Math.max(0, IN[i][0] * x + IN[i][1] * y + IN[i][2] * z))); - for (let i = 0; i < 3; i += 1) out[i] = Math.min(1, Math.max(0, OUT[i][0] * p[0] + OUT[i][1] * p[1] + OUT[i][2] * p[2])); + for (let i = 0; i < 3; i += 1) + out[i] = Math.min(1, Math.max(0, OUT[i][0] * p[0] + OUT[i][1] * p[1] + OUT[i][2] * p[2])); } /** The 8-bit sRGB of linear radiance, exposed and tone-mapped. */ @@ -44,7 +46,8 @@ export function toneMap8(r: number, g: number, b: number, exposure: number, out: } /** WCAG relative luminance of linear display RGB. */ -export const displayLuma = (c: ArrayLike): number => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; +export const displayLuma = (c: ArrayLike): number => + 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; /** What a sky state needs of its backdrop's display luminance, from the ink it wears (skyBand.ts, skyInk.ts). */ export interface Budget { @@ -90,7 +93,13 @@ const decode = (v: number): number => { return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; }; /** Display luminance (WCAG relative, from the encoded bytes) of a radiance through a display. */ -export function displayLumaOf(r: number, g: number, b: number, d: Display, tmp: number[] = [0, 0, 0]): number { +export function displayLumaOf( + r: number, + g: number, + b: number, + d: Display, + tmp: number[] = [0, 0, 0] +): number { display8(r, g, b, d, tmp); return displayLuma([decode(tmp[0]), decode(tmp[1]), decode(tmp[2])]); } @@ -113,7 +122,9 @@ export interface Fit { export function fitDisplay(state: FixedSkyState, hdr: Float32Array, meanLuma: number): Fit { const b = BUDGET[state]; const keyExposure = - b.key === undefined ? b.min : Math.min(b.max, Math.max(b.min, (b.key * 1.6) / Math.max(meanLuma, 1e-9))); + b.key === undefined + ? b.min + : Math.min(b.max, Math.max(b.min, (b.key * 1.6) / Math.max(meanLuma, 1e-9))); const n = hdr.length / 3; const stride = Math.max(1, Math.floor(n / 4000)); const tmp = [0, 0, 0]; @@ -187,7 +198,10 @@ export function holdBudget(state: FixedSkyState, hdr: Float32Array, d: Display): r = range(cur); } for (let k = 0; k < 8 && r.lo < b.floor; k += 1) { - cur = { exposure: cur.exposure, lift: cur.lift + ((b.floor - r.lo) / (1 - r.lo)) * 1.05 + 1e-4 }; + cur = { + exposure: cur.exposure, + lift: cur.lift + ((b.floor - r.lo) / (1 - r.lo)) * 1.05 + 1e-4, + }; r = range(cur); } return cur; diff --git a/src/app/features/scene/ridge/vienna.test.ts b/src/app/features/scene/ridge/vienna.test.ts index e0d4a4c7..4f26303c 100644 --- a/src/app/features/scene/ridge/vienna.test.ts +++ b/src/app/features/scene/ridge/vienna.test.ts @@ -31,7 +31,8 @@ describe('the sun over Vienna', () => { it('has a short day in December and a long one in June: published 8 h 4 min and 16 h 0 min with refraction, which adds about 6 minutes each end the ephemeris leaves out', () => { const daylight = (day: string) => { let n = 0; - for (let m = 0; m < 24 * 60; m += 1) if (viennaSun(new Date(Date.parse(day) + m * 60_000)).elevation > 0) n += 1; + for (let m = 0; m < 24 * 60; m += 1) + if (viennaSun(new Date(Date.parse(day) + m * 60_000)).elevation > 0) n += 1; return n; }; expect(Math.abs(daylight('2026-12-21T00:00:00Z') - (8 * 60 + 4))).toBeLessThan(15); diff --git a/src/app/features/scene/ridge/vienna.ts b/src/app/features/scene/ridge/vienna.ts index 56188226..f21082e5 100644 --- a/src/app/features/scene/ridge/vienna.ts +++ b/src/app/features/scene/ridge/vienna.ts @@ -33,7 +33,8 @@ export const STATE_ELEVATION: Record = night: [-90, -DAY_FROM], }; -const startOfUtcDay = (t: Date): number => Math.floor(t.getTime() / (24 * 60 * MIN)) * 24 * 60 * MIN; +const startOfUtcDay = (t: Date): number => + Math.floor(t.getTime() / (24 * 60 * MIN)) * 24 * 60 * MIN; /** The moment of the day (UTC day of `t`, every 5 minutes) the sun is highest over Vienna: solar noon to the step. */ export function solarNoon(t: Date): Date { diff --git a/src/app/features/scene/ridge/viennaHorizon.json b/src/app/features/scene/ridge/viennaHorizon.json index 62a4a126..adbc289a 100644 --- a/src/app/features/scene/ridge/viennaHorizon.json +++ b/src/app/features/scene/ridge/viennaHorizon.json @@ -1 +1,306 @@ -{"viewpoint":{"lat":48.1929826,"lon":16.3534237,"groundM":183.3},"stepDeg":0.5,"radiusKm":60.0,"minKm":1.0,"curvature":"drop d^2/2R*(1-0.13), R=6371000 m","source":{"dataset":"Copernicus DEM GLO-30 (COP-DEM_GLO-30-DGED), a DSM, EGM2008 heights, 1 arc-second grid","tiles":["N47_00_E015_00","N47_00_E016_00","N47_00_E017_00","N48_00_E015_00","N48_00_E016_00","N48_00_E017_00","N49_00_E015_00","N49_00_E016_00","N49_00_E017_00"],"tileBytesEach":"about 42-44 MB (Cloud Optimized GeoTIFF); nine tiles, about 380 MB, read once, not committed","url":"https://copernicus-dem-30m.s3.amazonaws.com/ (AWS Open Data, eu-central-1, no account)","licence":"Copernicus DEM licence: free of charge for any use incl. commercial, attribution required, no warranty (https://spacedata.copernicus.eu/collections/copernicus-digital-elevation-model, Licencing)","attribution":"Contains modified Copernicus data: DEM \u00a9 DLR e.V. 2010-2014 and \u00a9 Airbus Defence and Space GmbH 2014-2018 provided under COPERNICUS by the European Union and ESA; all rights reserved.","bakedBy":"scripts/bake-horizon.py"},"elevationDeg":[1.608,1.522,1.467,1.452,1.433,1.426,1.497,1.6,1.597,1.564,1.532,1.495,1.467,1.544,1.62,1.655,1.707,1.775,1.803,1.683,1.532,1.394,1.263,1.297,1.358,1.412,1.433,1.339,1.264,1.269,1.195,1.142,1.13,1.22,1.401,1.368,1.326,1.294,1.378,1.46,1.488,1.516,1.549,1.598,1.647,1.698,1.769,1.816,1.801,1.783,1.695,1.619,1.586,1.547,1.442,1.335,1.282,1.286,1.335,1.421,1.449,1.437,1.313,1.268,1.164,1.14,1.098,1.06,0.984,0.938,0.91,0.877,0.884,0.872,0.943,0.974,0.919,0.873,0.861,0.839,0.731,0.673,0.56,0.494,0.527,0.601,0.566,0.584,0.579,0.643,0.666,0.741,0.851,0.711,0.703,0.573,0.54,0.488,0.433,0.452,0.467,0.483,0.475,0.517,0.419,0.428,0.457,0.489,0.522,0.482,0.5,0.527,0.528,0.471,0.458,0.487,0.521,0.578,0.553,0.419,0.301,0.324,0.425,0.468,0.512,0.558,0.575,0.521,0.477,0.444,0.372,0.297,0.365,0.446,0.486,0.523,0.532,0.516,0.516,0.531,0.52,0.546,0.565,0.535,0.527,0.508,0.516,0.37,0.359,0.33,0.375,0.454,0.546,0.496,0.449,0.425,0.447,0.398,0.388,0.439,0.476,0.496,0.517,0.541,0.494,0.439,0.383,0.411,0.502,0.621,0.578,0.548,0.568,0.642,0.713,0.708,0.654,0.704,0.741,0.709,0.677,0.661,0.66,0.666,0.662,0.651,0.699,0.727,0.746,0.809,0.788,0.799,0.793,0.702,0.734,0.79,0.754,0.704,0.616,0.598,0.708,0.719,0.706,0.704,0.726,0.826,0.849,0.85,0.813,0.853,0.926,0.971,0.918,1.003,1.0,0.927,0.985,1.031,1.043,1.067,1.13,1.022,1.02,1.043,1.101,1.144,1.099,1.082,1.064,1.2,1.238,1.16,1.084,1.202,1.326,1.39,1.429,1.405,1.332,1.358,1.361,1.357,1.354,1.335,1.319,1.376,1.309,1.289,1.257,1.283,1.237,1.296,1.353,1.259,1.314,1.236,1.307,1.344,1.36,1.251,1.292,1.308,1.26,1.288,1.365,1.391,1.414,1.32,1.299,1.35,1.288,1.361,1.303,1.268,1.29,1.305,1.326,1.344,1.328,1.32,1.358,1.375,1.443,1.477,1.492,1.525,1.513,1.422,1.314,1.294,1.337,1.414,1.343,1.397,1.431,1.409,1.378,1.423,1.438,1.343,1.257,1.208,1.209,1.233,1.251,1.267,1.274,1.248,1.254,1.297,1.34,1.359,1.34,1.199,1.162,1.255,1.292,1.29,1.24,1.222,1.24,1.271,1.264,1.317,1.256,1.26,1.321,1.301,1.266,1.268,1.308,1.357,1.474,1.419,1.438,1.393,1.354,1.343,1.354,1.333,1.321,1.359,1.437,1.47,1.395,1.413,1.446,1.466,1.434,1.446,1.458,1.419,1.522,1.502,1.506,1.512,1.528,1.526,1.485,1.427,1.508,1.5,1.48,1.534,1.514,1.449,1.449,1.386,1.368,1.388,1.404,1.374,1.374,1.352,1.3,1.347,1.332,1.265,1.235,1.39,1.285,1.226,1.233,1.235,1.211,1.164,1.183,1.217,1.143,1.128,1.117,1.112,1.086,1.071,1.118,1.103,1.002,0.99,0.995,1.025,0.998,1.024,0.971,1.045,1.09,1.11,1.108,1.081,1.171,1.308,1.446,1.482,1.484,1.494,1.469,1.472,1.479,1.493,1.467,1.388,1.293,1.203,1.104,1.145,1.12,1.028,1.004,0.982,0.968,0.933,0.97,0.986,1.035,1.12,1.227,1.281,1.316,1.312,1.273,1.187,1.205,1.197,1.199,1.176,1.177,1.142,1.19,1.377,1.369,1.322,1.295,1.385,1.493,1.576,1.679,1.769,1.622,1.711,1.641,1.517,1.582,1.525,1.39,1.389,1.343,1.331,1.369,1.343,1.257,1.212,1.134,1.039,1.03,1.075,1.027,1.059,1.112,1.132,1.14,1.16,1.167,1.178,1.179,1.192,1.214,1.245,1.289,1.286,1.279,1.259,1.256,1.202,1.138,1.159,1.116,1.12,1.102,0.999,1.042,1.051,1.09,1.143,1.244,1.269,1.296,1.353,1.412,1.579,1.686,1.814,1.857,1.773,1.731,1.748,1.742,1.668,1.594,1.628,1.692,1.76,1.822,1.863,1.904,1.895,1.849,1.786,1.778,1.731,1.666,1.633,1.561,1.526,1.662,1.747,1.75,1.704,1.631,1.563,1.473,1.501,1.488,1.464,1.441,1.386,1.31,1.298,1.293,1.306,1.371,1.42,1.416,1.417,1.418,1.409,1.399,1.408,1.431,1.434,1.437,1.509,1.544,1.58,1.674,1.528,1.534,1.517,1.497,1.525,1.546,1.562,1.58,1.55,1.634,1.614,1.66,1.643,1.663,1.68,1.829,1.99,2.058,2.014,1.911,2.025,2.023,2.036,2.031,2.009,2.012,2.02,2.109,2.107,2.12,2.171,2.179,2.125,2.084,2.09,2.129,2.183,2.16,2.172,2.239,2.237,2.218,2.217,2.137,2.169,2.203,2.233,2.262,2.272,2.21,2.133,1.955,1.909,1.97,2.138,2.187,2.229,2.278,2.191,2.167,2.213,2.327,2.409,2.31,2.242,2.205,2.152,2.154,2.258,2.217,2.126,2.054,1.97,1.942,2.022,1.894,1.886,1.921,1.897,1.936,1.964,1.978,1.961,1.927,1.879,1.904,1.96,1.9,1.922,2.023,2.061,2.115,1.99,1.805,1.736,1.776,1.841,1.863,1.934,1.942,1.962,2.043,2.082,2.082,2.092,2.062,1.957,1.794,1.821,1.913,2.009,2.066,2.105,2.036,1.964,1.984,2.005,2.036,2.072,2.094,2.022,2.153,2.177,2.146,2.125,2.061,2.004,1.955,1.902,1.84,1.767,1.7,1.696,1.718,1.725,1.709,1.745,1.805,1.836,1.939,2.053,2.14,2.139,2.142,2.035,1.902,1.779,1.667,1.564,1.57,1.581,1.628,1.693,1.739,1.738,1.738,1.675],"distanceKm":[1.0,1.0,1.06,1.06,1.06,1.06,1.06,1.03,1.0,1.0,1.0,1.0,1.09,1.09,1.0,1.09,1.09,1.09,1.09,1.09,1.09,1.09,1.09,1.12,1.12,1.12,1.12,1.12,1.12,1.09,1.09,1.09,1.09,1.0,1.0,1.0,1.0,1.03,1.06,1.06,1.03,1.03,1.03,1.03,1.03,1.03,1.0,1.0,1.0,1.0,1.0,1.03,1.03,1.03,1.03,1.03,1.0,1.0,1.06,1.06,1.06,1.06,1.06,1.12,1.0,1.0,1.24,1.24,1.21,1.21,1.3,1.3,1.27,1.3,1.33,1.33,1.33,1.33,1.27,1.24,1.27,1.27,1.27,1.03,1.03,1.42,1.84,1.84,1.81,1.03,1.72,1.63,1.63,1.63,1.33,1.33,1.06,1.06,1.72,1.72,1.69,1.69,1.69,1.69,1.69,1.81,1.81,1.75,1.51,1.75,1.75,1.75,1.75,1.75,1.72,1.72,1.72,1.72,1.72,1.72,1.72,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.42,1.42,1.24,1.3,1.3,1.3,1.3,1.3,1.3,1.3,1.27,1.03,1.36,1.36,1.36,1.36,1.36,1.45,1.45,1.09,1.12,1.12,1.54,1.57,1.48,1.48,1.48,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.18,1.18,1.21,1.21,1.21,1.12,1.12,1.12,1.12,1.12,1.15,1.15,1.15,1.15,1.18,1.15,1.15,1.24,1.24,1.27,1.27,1.24,1.24,1.24,1.27,1.27,1.27,1.9,1.9,1.93,1.93,1.93,1.99,1.99,1.99,1.99,1.9,2.02,2.02,2.02,2.05,2.02,1.99,1.99,1.99,1.48,1.21,1.21,1.57,1.69,1.69,1.66,1.39,1.39,1.39,1.54,1.6,1.54,1.54,1.54,1.48,1.45,1.21,1.21,1.21,1.27,1.39,1.39,1.36,1.39,1.39,1.39,1.33,1.33,1.33,1.33,1.33,1.48,1.45,1.39,1.39,1.18,1.18,1.18,1.48,1.48,1.27,1.24,1.27,1.36,1.36,1.39,1.33,1.3,1.3,1.3,1.3,1.3,1.3,1.33,1.0,1.42,1.42,1.27,1.27,1.27,1.27,1.03,1.03,1.03,1.03,1.03,1.03,1.03,1.03,1.06,1.06,1.03,1.03,1.03,1.36,1.06,1.06,1.06,1.06,1.09,1.09,1.18,1.21,1.09,1.09,1.09,1.09,1.09,1.09,1.09,1.09,1.09,1.09,1.15,1.15,1.36,1.36,1.36,1.36,1.33,1.33,3.13,1.21,1.21,3.34,3.31,2.83,2.65,2.65,1.3,2.29,1.3,1.15,1.15,1.15,2.29,2.68,2.41,2.23,2.23,2.2,1.12,2.23,2.17,2.17,2.17,1.84,2.74,1.87,1.87,2.95,2.2,2.17,2.17,2.35,2.35,2.23,2.23,2.11,2.14,2.08,2.2,2.2,2.2,2.2,2.2,2.2,2.41,2.41,2.38,2.41,2.41,2.44,2.44,2.44,1.99,2.08,2.08,2.08,2.08,2.05,2.05,2.5,2.38,2.47,2.29,2.23,2.17,1.69,1.69,2.38,2.47,2.38,2.17,2.71,2.71,2.2,2.47,2.41,2.41,2.32,2.38,2.38,2.08,2.5,2.11,2.11,1.75,1.75,1.48,1.81,1.81,1.78,1.78,1.87,17.2,17.2,17.26,17.32,17.44,18.1,17.56,17.5,17.38,17.5,17.62,17.68,17.74,17.8,14.86,14.92,14.98,2.98,26.38,26.38,26.38,26.62,26.14,26.02,26.02,26.38,26.26,26.38,26.38,26.26,26.26,26.26,14.98,14.98,14.98,14.44,13.12,13.18,13.66,13.72,13.72,13.84,13.84,13.84,13.72,12.52,12.58,12.58,12.28,12.22,12.28,16.54,14.02,15.04,16.18,16.24,17.08,17.26,17.44,17.5,17.44,17.32,17.44,17.02,13.96,14.14,9.22,9.16,9.1,9.22,9.28,9.28,9.22,9.16,9.16,9.34,8.68,8.68,8.62,8.74,8.86,8.92,8.86,8.92,8.92,11.74,14.08,11.98,12.22,12.04,34.3,10.36,10.06,9.64,9.58,9.58,9.7,9.7,10.18,10.18,10.18,10.24,10.06,10.48,9.94,9.82,9.76,9.7,8.02,8.14,8.14,8.14,8.08,8.56,8.38,8.38,8.32,8.38,8.02,8.02,8.02,8.14,8.2,7.9,7.9,7.84,7.78,7.66,7.72,7.72,7.72,1.0,1.0,1.0,1.0,1.0,1.45,1.21,1.21,1.3,1.15,1.15,1.15,1.15,1.15,1.15,1.15,1.33,1.33,1.33,1.48,1.3,1.3,1.3,1.42,1.09,2.02,1.81,2.59,2.59,2.53,1.21,1.75,1.69,1.66,2.38,1.57,1.57,1.57,7.18,7.18,7.24,7.24,7.18,7.12,1.0,1.0,1.0,1.0,1.0,7.36,7.3,7.3,7.18,7.24,7.06,7.06,7.06,7.06,7.0,7.12,7.12,7.12,7.0,7.0,7.0,7.0,7.24,7.24,7.06,7.18,7.0,7.06,7.0,7.0,6.94,7.0,7.24,1.03,1.03,1.03,1.0,1.0,1.0,1.06,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.03,1.03,1.03,1.03,1.03,1.03,1.27,1.27,1.27,1.24,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.09,1.09,1.06,1.06,1.06,1.06,1.06,1.09,1.09,9.64,9.58,9.64,9.64,9.7,9.7,9.7,9.7,9.7,9.64,9.64,9.64,9.7,1.03,1.03,1.03,1.03,1.03,1.03,9.52,1.0,1.0,9.4,8.68,8.68,8.68,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,9.52,9.46,9.46,9.46,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0],"peakHeightM":[211.4,209.9,210.5,210.2,209.9,209.7,211.0,212.1,211.2,210.6,210.1,209.4,211.3,212.7,211.6,214.8,215.8,217.1,217.7,215.4,212.5,209.9,207.4,208.7,209.9,211.0,211.4,209.5,208.1,207.5,206.1,205.1,204.8,204.6,207.8,207.2,206.5,206.6,208.8,210.4,210.1,210.6,211.2,212.1,213.0,213.9,214.2,215.0,214.8,214.5,212.9,212.5,211.9,211.2,209.3,207.3,205.7,205.8,208.1,209.6,210.2,209.9,207.6,208.2,203.7,203.2,207.2,206.3,204.2,203.2,204.0,203.3,203.0,203.2,205.3,206.0,204.7,203.7,202.5,201.5,199.6,198.3,195.8,192.2,192.8,198.3,201.7,202.3,201.8,194.9,203.5,204.5,207.7,203.7,199.7,196.7,193.3,192.4,196.5,197.1,197.2,197.7,197.5,198.7,195.8,197.0,197.9,198.4,197.2,198.2,198.7,199.6,199.6,197.9,197.2,198.1,199.1,200.8,200.1,196.0,192.5,189.0,190.8,191.5,192.3,193.1,193.4,192.4,191.7,191.1,189.8,190.8,192.5,193.0,194.4,195.2,195.5,195.1,195.1,195.4,195.2,195.5,193.5,196.1,195.9,195.5,195.6,192.2,192.5,191.8,190.5,192.2,194.0,196.8,195.7,194.4,195.0,193.7,190.1,191.0,191.7,192.0,192.4,192.8,192.0,191.0,190.0,191.8,193.7,196.5,195.6,194.9,194.5,195.9,197.3,197.2,196.1,197.5,198.2,197.6,196.9,197.0,196.6,196.7,197.7,197.5,198.9,199.5,199.5,200.9,200.4,201.1,201.0,198.9,207.9,209.7,208.9,207.2,204.3,204.3,208.1,208.5,208.1,206.9,209.1,212.7,213.5,214.0,212.2,213.2,215.7,217.3,207.1,204.6,204.5,208.8,212.5,213.9,213.7,209.3,210.8,208.2,210.9,212.6,213.0,214.2,213.0,211.4,210.3,208.7,209.5,207.9,207.4,212.6,215.6,216.4,218.1,217.5,215.7,214.9,215.0,214.9,214.8,214.4,217.5,218.3,215.2,214.7,209.3,209.8,208.9,216.9,218.4,211.3,211.8,210.8,214.4,215.3,216.4,212.4,212.7,213.1,212.0,212.6,214.4,215.0,216.2,206.4,215.6,216.9,211.9,213.6,212.3,211.5,206.5,206.8,207.2,207.5,207.2,207.1,207.8,208.1,210.0,210.7,210.2,210.8,210.5,217.2,207.7,207.3,208.1,209.5,208.9,209.9,212.8,213.1,209.6,210.4,210.7,208.9,207.3,206.3,206.4,206.8,207.2,207.5,208.9,208.4,213.2,214.2,215.2,215.7,214.5,211.2,247.4,209.9,210.7,259.2,255.7,244.2,241.1,242.6,212.1,236.3,211.9,208.7,209.9,209.5,234.2,243.1,238.7,236.5,241.0,238.1,211.5,237.8,234.9,234.5,234.9,226.3,246.9,227.9,230.4,259.6,237.2,237.1,238.4,243.8,242.5,239.9,240.4,235.8,240.4,238.1,241.4,241.7,242.3,242.2,240.6,238.4,247.1,246.8,245.2,248.2,247.4,245.4,245.4,242.7,231.1,234.0,234.5,233.4,233.5,231.9,230.1,242.5,239.0,238.2,233.0,237.7,232.3,219.6,219.9,235.0,235.9,232.0,228.4,241.4,237.8,226.9,231.8,230.5,229.4,227.0,230.1,229.5,219.9,226.9,220.2,221.3,214.0,214.8,208.5,216.5,217.9,218.0,217.9,218.8,554.9,596.3,639.3,651.8,655.9,677.7,654.8,653.7,652.5,660.3,655.7,632.9,605.1,578.6,484.8,496.7,491.5,237.4,693.3,682.9,676.3,665.1,672.6,677.4,699.5,746.5,793.0,820.8,836.9,831.7,813.8,774.3,513.7,511.5,512.0,493.9,464.6,457.9,479.7,526.0,524.0,515.7,509.3,531.0,553.8,538.4,562.8,582.7,541.4,558.5,545.3,640.0,583.8,599.0,593.8,595.2,603.6,604.7,620.9,614.5,586.6,570.3,549.1,511.8,447.5,462.2,354.3,358.3,365.5,371.3,373.8,377.0,376.8,377.3,377.6,383.6,372.4,377.1,382.3,384.6,386.4,384.8,382.9,375.9,365.9,430.3,471.0,427.2,428.5,403.2,887.7,380.7,381.5,382.0,397.5,401.8,409.1,418.8,441.2,470.9,490.1,514.8,516.4,515.3,490.4,489.5,486.6,472.2,410.8,419.1,428.3,437.9,444.8,466.6,466.6,465.3,456.6,449.4,436.6,430.0,421.0,419.8,411.4,398.0,416.8,426.6,425.1,415.1,407.2,398.0,385.9,209.6,209.3,208.9,208.5,207.5,216.6,210.8,210.7,213.0,210.9,211.9,211.8,211.8,211.8,211.6,211.4,216.1,216.6,216.7,220.6,217.6,218.4,219.2,224.9,212.4,237.7,231.4,251.4,252.7,252.0,216.4,231.7,229.2,230.8,250.7,228.9,228.5,229.0,397.4,416.0,438.4,447.0,439.3,424.3,218.7,218.7,218.9,218.8,218.4,445.6,444.4,455.8,451.0,454.8,454.3,455.3,448.6,443.6,442.1,451.4,458.2,455.3,452.1,460.3,460.0,457.7,467.1,457.0,454.1,463.0,459.5,465.5,464.3,456.8,445.0,425.5,428.2,218.8,221.8,222.7,222.3,223.1,221.6,223.5,222.0,224.0,225.4,223.7,222.5,221.8,220.9,222.1,224.0,223.2,221.6,220.3,218.8,226.4,228.2,225.4,224.2,216.9,216.5,217.1,217.6,217.9,217.6,217.0,216.1,216.6,220.6,219.5,218.9,220.8,221.5,222.5,220.2,217.7,216.4,488.4,497.5,503.2,515.2,518.5,522.0,535.7,542.3,542.3,541.8,536.7,518.9,493.5,216.1,217.8,219.5,220.5,221.2,220.0,516.0,218.0,218.4,523.4,502.5,505.8,494.9,220.9,221.3,220.8,220.4,219.3,218.3,217.5,216.5,215.5,214.2,213.0,212.9,213.3,213.5,473.5,477.6,487.5,492.6,217.2,219.2,220.7,220.7,220.7,218.9,216.5,214.4,212.5,210.6,210.7,210.9,211.8,212.9,213.7,213.7,213.7,212.6],"slopeEast":[0.002,-0.001,0.056,0.043,0.022,0.002,0.007,-0.005,0.014,0.012,0.002,-0.013,0.046,0.027,-0.083,-0.005,-0.017,-0.01,-0.007,-0.03,-0.051,-0.056,-0.058,-0.088,-0.109,-0.099,-0.085,-0.079,-0.077,0.008,0.005,0.006,-0.01,0.062,0.068,0.071,0.074,0.084,0.019,-0.001,0.023,0.02,0.021,0.024,0.025,0.031,0.054,0.031,0.003,-0.022,-0.034,-0.054,-0.055,-0.049,-0.047,-0.045,-0.038,-0.041,-0.051,-0.054,-0.073,-0.086,-0.104,-0.05,-0.085,-0.083,0.125,0.082,0.049,0.018,0.064,0.034,-0.013,-0.004,-0.06,-0.087,-0.128,-0.166,-0.129,-0.038,-0.154,-0.158,-0.163,-0.116,-0.12,0.088,0.055,0.048,0.095,0.036,0.154,0.016,-0.009,-0.018,-0.079,-0.121,-0.161,-0.174,0.003,0.037,0.108,0.103,0.1,0.106,0.101,-0.064,-0.045,0.013,0.149,0.083,0.078,0.071,0.039,-0.062,-0.065,-0.118,-0.105,-0.072,-0.045,-0.025,-0.001,-0.008,-0.007,-0.02,-0.037,-0.058,-0.078,-0.075,-0.069,-0.063,-0.054,-0.008,0.003,0.123,0.036,0.015,-0.005,0.017,0.049,0.086,0.068,0.013,0.032,-0.024,-0.01,0.005,0.009,-0.005,-0.004,0.021,0.062,-0.012,-0.014,-0.013,-0.031,0.02,-0.004,-0.021,-0.005,-0.018,-0.026,-0.032,-0.038,-0.046,-0.055,-0.063,-0.067,-0.007,0.009,-0.048,-0.051,-0.051,-0.004,-0.015,-0.025,-0.018,0.002,-0.002,0.005,0.013,0.02,0.049,0.013,0.002,-0.023,-0.009,-0.075,-0.065,0.018,0.02,0.024,-0.023,-0.022,-0.049,0.01,0.056,-0.003,-0.001,0.008,-0.048,-0.05,-0.03,-0.015,0.002,-0.045,-0.077,-0.052,-0.113,-0.035,0.052,0.029,-0.006,-0.008,0.119,0.111,0.049,0.019,-0.001,-0.008,0.003,0.025,0.028,0.004,-0.059,0.008,0.005,-0.0,0.055,0.03,-0.044,-0.024,-0.002,-0.029,-0.015,-0.028,-0.04,-0.079,-0.084,-0.073,0.118,0.104,0.095,0.098,0.122,-0.042,0.014,0.108,0.117,-0.077,-0.066,-0.052,0.006,0.018,-0.026,-0.008,0.016,-0.019,-0.02,0.006,0.033,0.056,0.035,0.017,0.002,0.011,0.009,-0.015,0.039,-0.016,-0.019,0.073,0.06,0.028,0.012,-0.052,-0.037,-0.027,-0.026,-0.025,-0.027,-0.034,-0.033,-0.112,-0.098,-0.018,-0.005,0.014,-0.002,-0.012,-0.021,-0.041,-0.06,-0.073,-0.068,-0.007,0.023,-0.002,0.014,0.031,0.042,0.045,0.05,0.06,0.075,0.066,0.054,0.029,0.03,-0.066,-0.045,-0.033,-0.035,0.1,0.072,0.022,-0.017,0.002,0.006,0.105,0.007,-0.015,0.014,-0.019,-0.045,0.021,-0.027,-0.032,-0.043,0.023,-0.048,0.051,-0.072,-0.099,-0.013,0.016,0.062,0.05,0.066,0.041,0.022,-0.031,-0.028,-0.003,0.047,-0.019,-0.007,0.014,-0.047,0.019,-0.023,-0.023,0.008,-0.064,0.049,-0.035,-0.026,0.01,0.043,0.057,0.094,-0.076,-0.07,0.043,0.029,0.051,0.001,0.036,0.056,-0.016,-0.042,-0.015,-0.0,0.037,0.052,0.089,0.032,0.021,0.06,-0.018,0.018,0.002,0.036,0.058,-0.043,0.005,-0.0,-0.004,-0.084,-0.088,0.055,-0.004,0.02,0.027,0.018,-0.003,-0.013,-0.01,-0.005,0.032,0.037,-0.085,-0.114,0.016,-0.076,-0.081,0.043,0.029,0.014,-0.247,-0.334,-0.183,-0.03,-0.023,0.027,0.067,0.018,-0.17,0.04,0.076,0.16,0.142,0.214,-0.239,-0.036,0.236,0.006,-0.075,-0.015,-0.134,0.191,-0.098,-0.235,-0.094,-0.348,-0.156,-0.126,0.06,-0.008,0.039,0.184,0.048,0.037,0.035,-0.057,-0.06,0.062,-0.295,-0.071,-0.175,0.046,-0.045,-0.173,-0.173,-0.161,-0.148,0.042,-0.034,-0.145,0.247,0.002,-0.209,0.028,-0.129,-0.037,0.154,-0.069,-0.023,0.149,0.119,-0.146,0.175,0.112,-0.005,-0.029,-0.11,-0.064,-0.06,-0.018,-0.04,0.03,-0.087,-0.043,-0.015,-0.031,-0.13,-0.068,-0.06,0.001,-0.021,-0.0,-0.013,0.032,0.012,-0.025,0.013,0.081,0.008,-0.101,0.006,-0.063,-0.072,-0.034,-0.109,-0.03,-0.058,-0.068,-0.174,-0.1,-0.092,-0.088,-0.053,0.072,0.007,-0.043,-0.029,-0.004,-0.033,-0.07,-0.071,-0.07,-0.077,-0.07,-0.021,-0.02,-0.061,-0.004,-0.114,-0.02,-0.023,0.032,0.05,-0.037,-0.005,-0.02,-0.109,-0.063,0.014,-0.015,-0.037,0.007,0.019,0.035,0.051,0.064,-0.018,-0.034,-0.025,0.038,-0.074,-0.071,-0.068,-0.065,-0.061,-0.056,-0.05,-0.038,-0.032,-0.02,0.035,-0.023,-0.034,-0.042,-0.009,-0.053,-0.061,-0.048,0.032,0.055,-0.052,0.019,0.04,-0.026,0.007,-0.033,-0.04,-0.044,-0.047,0.134,-0.002,0.095,0.061,-0.082,-0.127,0.039,0.057,0.077,0.092,0.081,0.007,-0.041,0.057,-0.038,0.02,0.004,-0.126,-0.118,-0.042,-0.061,-0.009,-0.034,-0.028,-0.016,0.066,-0.008,-0.033,-0.062,-0.168,0.054,0.058,-0.007,0.007,-0.054,-0.042,-0.257,-0.152,0.009,0.029,0.032,0.036,-0.033,-0.046,-0.05,0.018,-0.059,-0.065,-0.078,-0.093,-0.109,-0.126,-0.136,-0.072,-0.057,-0.05,-0.039,-0.032,-0.029,-0.024,-0.055,-0.05,-0.057,0.019,0.031,0.057,0.078,0.091,0.084,0.071,0.05,0.033,0.066,0.024,-0.039,-0.043,-0.062,-0.086,-0.078,-0.035,-0.041,0.114,-0.004,0.08,-0.043,0.008,0.035,0.117,0.075,0.091,-0.088,-0.074,-0.157,-0.187,0.023,0.023,0.021,0.021,0.017,0.002,-0.01,0.054,0.037,0.016,0.058,-0.096,-0.292,0.009,0.007,-0.003,-0.011,-0.009,-0.005,-0.017,-0.031,-0.04,-0.029,-0.019,0.007,0.036,0.064,0.06,0.091,0.084,0.021,0.044,0.024,0.007,0.007,0.006,-0.001,-0.004,-0.011,-0.026,-0.037,-0.048,-0.06,-0.057,-0.054,-0.051,-0.031,-0.014,-0.007],"slopeNorth":[-0.005,0.005,-0.003,0.002,-0.006,-0.016,-0.044,-0.027,-0.024,-0.026,-0.026,-0.026,-0.05,-0.054,0.02,-0.042,-0.033,-0.022,-0.009,0.008,0.025,0.032,0.037,0.003,0.01,0.01,0.013,0.017,0.026,0.059,0.018,-0.02,-0.039,0.024,0.024,0.008,-0.005,-0.003,-0.06,-0.088,-0.024,-0.033,-0.037,-0.039,-0.046,-0.052,-0.026,-0.019,-0.023,-0.025,-0.027,-0.106,-0.113,-0.118,-0.111,-0.103,-0.016,-0.016,-0.129,-0.122,-0.096,-0.075,-0.046,-0.071,0.056,0.061,-0.108,-0.077,0.028,0.067,-0.029,-0.009,0.048,-0.009,-0.029,-0.018,0.006,0.025,0.144,0.19,0.17,0.167,0.149,-0.078,-0.083,-0.0,-0.081,-0.04,-0.001,-0.033,-0.082,-0.031,0.061,0.14,0.078,0.132,0.104,0.127,-0.011,-0.01,0.071,0.129,0.113,0.103,0.13,-0.038,-0.037,0.044,0.052,0.016,0.01,-0.01,-0.02,-0.014,0.06,0.086,0.082,0.067,0.049,0.051,0.058,-0.08,-0.095,-0.099,-0.098,-0.093,-0.064,-0.024,0.007,0.03,0.041,-0.085,-0.064,-0.021,-0.069,-0.063,-0.054,-0.027,0.013,0.058,0.082,0.091,0.065,0.04,0.085,0.117,0.139,0.134,0.059,0.047,0.008,0.002,0.005,0.01,0.034,0.039,0.068,0.077,-0.041,-0.017,-0.011,-0.017,-0.021,-0.021,-0.003,0.016,0.034,-0.057,-0.046,-0.067,-0.062,-0.059,-0.032,-0.028,-0.023,-0.01,-0.01,-0.033,-0.028,-0.004,0.019,-0.01,0.056,0.067,-0.061,-0.049,-0.108,-0.079,-0.0,0.018,0.024,0.053,0.08,0.085,-0.008,0.015,0.016,0.027,0.048,-0.05,-0.038,-0.058,-0.086,-0.001,-0.061,-0.038,-0.014,0.027,0.062,-0.036,-0.007,0.046,-0.023,-0.001,0.01,-0.073,0.053,0.073,-0.02,0.004,-0.001,-0.014,-0.069,-0.016,-0.041,0.001,0.021,-0.017,-0.019,-0.115,-0.11,-0.083,-0.119,-0.097,-0.076,-0.101,-0.069,-0.054,-0.022,-0.045,-0.028,-0.016,-0.008,0.019,-0.037,0.005,0.007,0.01,-0.092,-0.096,-0.079,0.074,0.07,-0.076,-0.025,-0.075,-0.039,0.003,-0.006,0.013,-0.03,-0.064,-0.079,-0.076,-0.056,-0.026,0.042,-0.084,0.038,0.055,0.002,0.033,0.01,-0.003,0.001,-0.02,-0.041,-0.057,-0.056,-0.053,-0.045,-0.037,0.043,0.037,-0.023,-0.013,-0.012,0.063,0.029,0.024,0.012,-0.007,0.01,0.006,-0.057,0.007,-0.017,0.02,0.061,0.058,0.039,0.021,0.001,-0.02,-0.025,-0.024,0.039,0.052,0.03,0.03,0.028,0.035,0.003,0.005,0.027,-0.048,-0.059,0.005,-0.037,-0.029,-0.058,-0.066,-0.009,-0.02,0.014,-0.009,-0.015,-0.01,-0.045,-0.036,-0.085,-0.017,0.011,-0.052,0.048,-0.025,-0.024,-0.035,-0.031,-0.05,-0.058,-0.013,-0.05,-0.053,-0.058,-0.033,-0.017,0.001,-0.025,-0.066,-0.023,-0.014,-0.011,-0.059,-0.019,-0.009,0.005,-0.007,-0.034,-0.051,0.002,0.036,0.0,0.007,-0.022,-0.028,-0.046,-0.051,-0.018,-0.021,-0.013,0.012,-0.005,-0.035,-0.036,0.011,-0.065,0.025,0.006,0.018,-0.103,0.005,-0.007,-0.025,-0.061,-0.044,-0.049,-0.008,0.004,-0.043,0.022,-0.062,-0.019,-0.055,0.001,-0.01,-0.057,-0.005,-0.051,-0.064,-0.023,0.029,-0.044,0.079,0.066,-0.049,-0.043,0.043,0.037,0.009,-0.04,-0.175,-0.046,-0.025,-0.055,-0.045,0.01,-0.078,-0.036,-0.127,-0.126,-0.145,0.102,0.094,-0.123,0.055,-0.109,0.133,-0.142,-0.009,-0.085,0.067,0.024,0.056,-0.06,0.156,0.025,-0.068,0.018,-0.106,0.077,0.019,-0.02,-0.043,0.021,-0.021,0.053,-0.023,-0.091,-0.044,0.016,0.174,0.024,0.172,0.213,-0.05,-0.021,-0.051,-0.255,0.041,0.076,-0.168,0.031,-0.153,-0.058,-0.022,0.063,-0.084,-0.122,-0.153,-0.157,-0.171,0.014,0.054,0.036,0.084,0.009,0.019,0.018,-0.012,0.013,0.008,0.011,0.061,0.061,0.057,-0.013,-0.036,-0.024,-0.045,-0.046,-0.101,-0.156,-0.066,-0.239,-0.1,-0.077,-0.243,-0.08,0.073,0.039,0.179,0.106,0.005,0.086,0.044,0.321,0.232,0.195,0.145,-0.074,-0.069,-0.057,0.021,-0.1,-0.165,0.063,0.139,0.12,0.091,0.134,0.082,0.064,-0.056,-0.118,-0.146,-0.004,-0.123,-0.142,-0.092,-0.121,0.172,0.209,0.083,-0.15,-0.088,-0.128,-0.152,-0.196,0.004,-0.017,-0.027,-0.037,-0.042,0.021,0.014,0.009,0.003,0.082,0.074,0.061,0.048,0.031,0.013,-0.006,0.081,0.074,0.055,-0.036,0.041,0.024,0.006,-0.068,-0.022,0.073,0.021,0.055,0.009,0.022,0.079,-0.019,0.052,0.08,0.02,0.073,0.04,0.01,0.224,0.291,0.218,-0.011,-0.137,-0.234,0.091,0.094,0.095,0.093,0.062,0.02,0.061,0.003,0.044,-0.063,0.145,-0.012,-0.046,-0.011,0.075,0.117,0.046,-0.033,0.108,0.084,-0.011,-0.094,-0.024,-0.13,0.075,0.1,0.124,-0.018,-0.058,-0.136,-0.149,-0.227,-0.022,0.105,0.119,0.113,0.09,0.076,0.045,-0.045,0.032,0.029,0.034,0.051,0.055,0.044,0.028,-0.072,-0.095,-0.088,-0.06,-0.032,-0.005,0.003,-0.016,-0.008,0.039,0.085,0.076,0.061,0.046,0.035,0.031,0.022,0.018,0.014,0.006,0.014,0.009,-0.002,-0.002,0.003,-0.003,-0.024,-0.018,0.068,0.212,0.049,0.187,0.06,0.024,0.003,-0.084,-0.173,0.03,-0.138,-0.17,-0.157,0.006,-0.007,-0.026,-0.034,-0.038,-0.045,0.001,-0.075,-0.067,0.237,0.215,0.109,0.055,-0.068,-0.066,-0.059,-0.054,-0.059,-0.062,-0.065,-0.07,-0.072,-0.064,-0.054,-0.045,-0.035,-0.022,0.059,0.161,0.071,0.021,-0.021,-0.027,-0.033,-0.039,-0.044,-0.038,-0.029,-0.034,-0.057,-0.078,-0.075,-0.071,-0.076,-0.085,-0.083,-0.057,-0.03,-0.018]} +{ + "viewpoint": { "lat": 48.1929826, "lon": 16.3534237, "groundM": 183.3 }, + "stepDeg": 0.5, + "radiusKm": 60.0, + "minKm": 1.0, + "curvature": "drop d^2/2R*(1-0.13), R=6371000 m", + "source": { + "dataset": "Copernicus DEM GLO-30 (COP-DEM_GLO-30-DGED), a DSM, EGM2008 heights, 1 arc-second grid", + "tiles": [ + "N47_00_E015_00", + "N47_00_E016_00", + "N47_00_E017_00", + "N48_00_E015_00", + "N48_00_E016_00", + "N48_00_E017_00", + "N49_00_E015_00", + "N49_00_E016_00", + "N49_00_E017_00" + ], + "tileBytesEach": "about 42-44 MB (Cloud Optimized GeoTIFF); nine tiles, about 380 MB, read once, not committed", + "url": "https://copernicus-dem-30m.s3.amazonaws.com/ (AWS Open Data, eu-central-1, no account)", + "licence": "Copernicus DEM licence: free of charge for any use incl. commercial, attribution required, no warranty (https://spacedata.copernicus.eu/collections/copernicus-digital-elevation-model, Licencing)", + "attribution": "Contains modified Copernicus data: DEM \u00a9 DLR e.V. 2010-2014 and \u00a9 Airbus Defence and Space GmbH 2014-2018 provided under COPERNICUS by the European Union and ESA; all rights reserved.", + "bakedBy": "scripts/bake-horizon.py" + }, + "elevationDeg": [ + 1.608, 1.522, 1.467, 1.452, 1.433, 1.426, 1.497, 1.6, 1.597, 1.564, 1.532, 1.495, 1.467, 1.544, + 1.62, 1.655, 1.707, 1.775, 1.803, 1.683, 1.532, 1.394, 1.263, 1.297, 1.358, 1.412, 1.433, 1.339, + 1.264, 1.269, 1.195, 1.142, 1.13, 1.22, 1.401, 1.368, 1.326, 1.294, 1.378, 1.46, 1.488, 1.516, + 1.549, 1.598, 1.647, 1.698, 1.769, 1.816, 1.801, 1.783, 1.695, 1.619, 1.586, 1.547, 1.442, + 1.335, 1.282, 1.286, 1.335, 1.421, 1.449, 1.437, 1.313, 1.268, 1.164, 1.14, 1.098, 1.06, 0.984, + 0.938, 0.91, 0.877, 0.884, 0.872, 0.943, 0.974, 0.919, 0.873, 0.861, 0.839, 0.731, 0.673, 0.56, + 0.494, 0.527, 0.601, 0.566, 0.584, 0.579, 0.643, 0.666, 0.741, 0.851, 0.711, 0.703, 0.573, 0.54, + 0.488, 0.433, 0.452, 0.467, 0.483, 0.475, 0.517, 0.419, 0.428, 0.457, 0.489, 0.522, 0.482, 0.5, + 0.527, 0.528, 0.471, 0.458, 0.487, 0.521, 0.578, 0.553, 0.419, 0.301, 0.324, 0.425, 0.468, + 0.512, 0.558, 0.575, 0.521, 0.477, 0.444, 0.372, 0.297, 0.365, 0.446, 0.486, 0.523, 0.532, + 0.516, 0.516, 0.531, 0.52, 0.546, 0.565, 0.535, 0.527, 0.508, 0.516, 0.37, 0.359, 0.33, 0.375, + 0.454, 0.546, 0.496, 0.449, 0.425, 0.447, 0.398, 0.388, 0.439, 0.476, 0.496, 0.517, 0.541, + 0.494, 0.439, 0.383, 0.411, 0.502, 0.621, 0.578, 0.548, 0.568, 0.642, 0.713, 0.708, 0.654, + 0.704, 0.741, 0.709, 0.677, 0.661, 0.66, 0.666, 0.662, 0.651, 0.699, 0.727, 0.746, 0.809, 0.788, + 0.799, 0.793, 0.702, 0.734, 0.79, 0.754, 0.704, 0.616, 0.598, 0.708, 0.719, 0.706, 0.704, 0.726, + 0.826, 0.849, 0.85, 0.813, 0.853, 0.926, 0.971, 0.918, 1.003, 1.0, 0.927, 0.985, 1.031, 1.043, + 1.067, 1.13, 1.022, 1.02, 1.043, 1.101, 1.144, 1.099, 1.082, 1.064, 1.2, 1.238, 1.16, 1.084, + 1.202, 1.326, 1.39, 1.429, 1.405, 1.332, 1.358, 1.361, 1.357, 1.354, 1.335, 1.319, 1.376, 1.309, + 1.289, 1.257, 1.283, 1.237, 1.296, 1.353, 1.259, 1.314, 1.236, 1.307, 1.344, 1.36, 1.251, 1.292, + 1.308, 1.26, 1.288, 1.365, 1.391, 1.414, 1.32, 1.299, 1.35, 1.288, 1.361, 1.303, 1.268, 1.29, + 1.305, 1.326, 1.344, 1.328, 1.32, 1.358, 1.375, 1.443, 1.477, 1.492, 1.525, 1.513, 1.422, 1.314, + 1.294, 1.337, 1.414, 1.343, 1.397, 1.431, 1.409, 1.378, 1.423, 1.438, 1.343, 1.257, 1.208, + 1.209, 1.233, 1.251, 1.267, 1.274, 1.248, 1.254, 1.297, 1.34, 1.359, 1.34, 1.199, 1.162, 1.255, + 1.292, 1.29, 1.24, 1.222, 1.24, 1.271, 1.264, 1.317, 1.256, 1.26, 1.321, 1.301, 1.266, 1.268, + 1.308, 1.357, 1.474, 1.419, 1.438, 1.393, 1.354, 1.343, 1.354, 1.333, 1.321, 1.359, 1.437, 1.47, + 1.395, 1.413, 1.446, 1.466, 1.434, 1.446, 1.458, 1.419, 1.522, 1.502, 1.506, 1.512, 1.528, + 1.526, 1.485, 1.427, 1.508, 1.5, 1.48, 1.534, 1.514, 1.449, 1.449, 1.386, 1.368, 1.388, 1.404, + 1.374, 1.374, 1.352, 1.3, 1.347, 1.332, 1.265, 1.235, 1.39, 1.285, 1.226, 1.233, 1.235, 1.211, + 1.164, 1.183, 1.217, 1.143, 1.128, 1.117, 1.112, 1.086, 1.071, 1.118, 1.103, 1.002, 0.99, 0.995, + 1.025, 0.998, 1.024, 0.971, 1.045, 1.09, 1.11, 1.108, 1.081, 1.171, 1.308, 1.446, 1.482, 1.484, + 1.494, 1.469, 1.472, 1.479, 1.493, 1.467, 1.388, 1.293, 1.203, 1.104, 1.145, 1.12, 1.028, 1.004, + 0.982, 0.968, 0.933, 0.97, 0.986, 1.035, 1.12, 1.227, 1.281, 1.316, 1.312, 1.273, 1.187, 1.205, + 1.197, 1.199, 1.176, 1.177, 1.142, 1.19, 1.377, 1.369, 1.322, 1.295, 1.385, 1.493, 1.576, 1.679, + 1.769, 1.622, 1.711, 1.641, 1.517, 1.582, 1.525, 1.39, 1.389, 1.343, 1.331, 1.369, 1.343, 1.257, + 1.212, 1.134, 1.039, 1.03, 1.075, 1.027, 1.059, 1.112, 1.132, 1.14, 1.16, 1.167, 1.178, 1.179, + 1.192, 1.214, 1.245, 1.289, 1.286, 1.279, 1.259, 1.256, 1.202, 1.138, 1.159, 1.116, 1.12, 1.102, + 0.999, 1.042, 1.051, 1.09, 1.143, 1.244, 1.269, 1.296, 1.353, 1.412, 1.579, 1.686, 1.814, 1.857, + 1.773, 1.731, 1.748, 1.742, 1.668, 1.594, 1.628, 1.692, 1.76, 1.822, 1.863, 1.904, 1.895, 1.849, + 1.786, 1.778, 1.731, 1.666, 1.633, 1.561, 1.526, 1.662, 1.747, 1.75, 1.704, 1.631, 1.563, 1.473, + 1.501, 1.488, 1.464, 1.441, 1.386, 1.31, 1.298, 1.293, 1.306, 1.371, 1.42, 1.416, 1.417, 1.418, + 1.409, 1.399, 1.408, 1.431, 1.434, 1.437, 1.509, 1.544, 1.58, 1.674, 1.528, 1.534, 1.517, 1.497, + 1.525, 1.546, 1.562, 1.58, 1.55, 1.634, 1.614, 1.66, 1.643, 1.663, 1.68, 1.829, 1.99, 2.058, + 2.014, 1.911, 2.025, 2.023, 2.036, 2.031, 2.009, 2.012, 2.02, 2.109, 2.107, 2.12, 2.171, 2.179, + 2.125, 2.084, 2.09, 2.129, 2.183, 2.16, 2.172, 2.239, 2.237, 2.218, 2.217, 2.137, 2.169, 2.203, + 2.233, 2.262, 2.272, 2.21, 2.133, 1.955, 1.909, 1.97, 2.138, 2.187, 2.229, 2.278, 2.191, 2.167, + 2.213, 2.327, 2.409, 2.31, 2.242, 2.205, 2.152, 2.154, 2.258, 2.217, 2.126, 2.054, 1.97, 1.942, + 2.022, 1.894, 1.886, 1.921, 1.897, 1.936, 1.964, 1.978, 1.961, 1.927, 1.879, 1.904, 1.96, 1.9, + 1.922, 2.023, 2.061, 2.115, 1.99, 1.805, 1.736, 1.776, 1.841, 1.863, 1.934, 1.942, 1.962, 2.043, + 2.082, 2.082, 2.092, 2.062, 1.957, 1.794, 1.821, 1.913, 2.009, 2.066, 2.105, 2.036, 1.964, + 1.984, 2.005, 2.036, 2.072, 2.094, 2.022, 2.153, 2.177, 2.146, 2.125, 2.061, 2.004, 1.955, + 1.902, 1.84, 1.767, 1.7, 1.696, 1.718, 1.725, 1.709, 1.745, 1.805, 1.836, 1.939, 2.053, 2.14, + 2.139, 2.142, 2.035, 1.902, 1.779, 1.667, 1.564, 1.57, 1.581, 1.628, 1.693, 1.739, 1.738, 1.738, + 1.675 + ], + "distanceKm": [ + 1.0, 1.0, 1.06, 1.06, 1.06, 1.06, 1.06, 1.03, 1.0, 1.0, 1.0, 1.0, 1.09, 1.09, 1.0, 1.09, 1.09, + 1.09, 1.09, 1.09, 1.09, 1.09, 1.09, 1.12, 1.12, 1.12, 1.12, 1.12, 1.12, 1.09, 1.09, 1.09, 1.09, + 1.0, 1.0, 1.0, 1.0, 1.03, 1.06, 1.06, 1.03, 1.03, 1.03, 1.03, 1.03, 1.03, 1.0, 1.0, 1.0, 1.0, + 1.0, 1.03, 1.03, 1.03, 1.03, 1.03, 1.0, 1.0, 1.06, 1.06, 1.06, 1.06, 1.06, 1.12, 1.0, 1.0, 1.24, + 1.24, 1.21, 1.21, 1.3, 1.3, 1.27, 1.3, 1.33, 1.33, 1.33, 1.33, 1.27, 1.24, 1.27, 1.27, 1.27, + 1.03, 1.03, 1.42, 1.84, 1.84, 1.81, 1.03, 1.72, 1.63, 1.63, 1.63, 1.33, 1.33, 1.06, 1.06, 1.72, + 1.72, 1.69, 1.69, 1.69, 1.69, 1.69, 1.81, 1.81, 1.75, 1.51, 1.75, 1.75, 1.75, 1.75, 1.75, 1.72, + 1.72, 1.72, 1.72, 1.72, 1.72, 1.72, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.42, + 1.42, 1.24, 1.3, 1.3, 1.3, 1.3, 1.3, 1.3, 1.3, 1.27, 1.03, 1.36, 1.36, 1.36, 1.36, 1.36, 1.45, + 1.45, 1.09, 1.12, 1.12, 1.54, 1.57, 1.48, 1.48, 1.48, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, + 1.0, 1.18, 1.18, 1.21, 1.21, 1.21, 1.12, 1.12, 1.12, 1.12, 1.12, 1.15, 1.15, 1.15, 1.15, 1.18, + 1.15, 1.15, 1.24, 1.24, 1.27, 1.27, 1.24, 1.24, 1.24, 1.27, 1.27, 1.27, 1.9, 1.9, 1.93, 1.93, + 1.93, 1.99, 1.99, 1.99, 1.99, 1.9, 2.02, 2.02, 2.02, 2.05, 2.02, 1.99, 1.99, 1.99, 1.48, 1.21, + 1.21, 1.57, 1.69, 1.69, 1.66, 1.39, 1.39, 1.39, 1.54, 1.6, 1.54, 1.54, 1.54, 1.48, 1.45, 1.21, + 1.21, 1.21, 1.27, 1.39, 1.39, 1.36, 1.39, 1.39, 1.39, 1.33, 1.33, 1.33, 1.33, 1.33, 1.48, 1.45, + 1.39, 1.39, 1.18, 1.18, 1.18, 1.48, 1.48, 1.27, 1.24, 1.27, 1.36, 1.36, 1.39, 1.33, 1.3, 1.3, + 1.3, 1.3, 1.3, 1.3, 1.33, 1.0, 1.42, 1.42, 1.27, 1.27, 1.27, 1.27, 1.03, 1.03, 1.03, 1.03, 1.03, + 1.03, 1.03, 1.03, 1.06, 1.06, 1.03, 1.03, 1.03, 1.36, 1.06, 1.06, 1.06, 1.06, 1.09, 1.09, 1.18, + 1.21, 1.09, 1.09, 1.09, 1.09, 1.09, 1.09, 1.09, 1.09, 1.09, 1.09, 1.15, 1.15, 1.36, 1.36, 1.36, + 1.36, 1.33, 1.33, 3.13, 1.21, 1.21, 3.34, 3.31, 2.83, 2.65, 2.65, 1.3, 2.29, 1.3, 1.15, 1.15, + 1.15, 2.29, 2.68, 2.41, 2.23, 2.23, 2.2, 1.12, 2.23, 2.17, 2.17, 2.17, 1.84, 2.74, 1.87, 1.87, + 2.95, 2.2, 2.17, 2.17, 2.35, 2.35, 2.23, 2.23, 2.11, 2.14, 2.08, 2.2, 2.2, 2.2, 2.2, 2.2, 2.2, + 2.41, 2.41, 2.38, 2.41, 2.41, 2.44, 2.44, 2.44, 1.99, 2.08, 2.08, 2.08, 2.08, 2.05, 2.05, 2.5, + 2.38, 2.47, 2.29, 2.23, 2.17, 1.69, 1.69, 2.38, 2.47, 2.38, 2.17, 2.71, 2.71, 2.2, 2.47, 2.41, + 2.41, 2.32, 2.38, 2.38, 2.08, 2.5, 2.11, 2.11, 1.75, 1.75, 1.48, 1.81, 1.81, 1.78, 1.78, 1.87, + 17.2, 17.2, 17.26, 17.32, 17.44, 18.1, 17.56, 17.5, 17.38, 17.5, 17.62, 17.68, 17.74, 17.8, + 14.86, 14.92, 14.98, 2.98, 26.38, 26.38, 26.38, 26.62, 26.14, 26.02, 26.02, 26.38, 26.26, 26.38, + 26.38, 26.26, 26.26, 26.26, 14.98, 14.98, 14.98, 14.44, 13.12, 13.18, 13.66, 13.72, 13.72, + 13.84, 13.84, 13.84, 13.72, 12.52, 12.58, 12.58, 12.28, 12.22, 12.28, 16.54, 14.02, 15.04, + 16.18, 16.24, 17.08, 17.26, 17.44, 17.5, 17.44, 17.32, 17.44, 17.02, 13.96, 14.14, 9.22, 9.16, + 9.1, 9.22, 9.28, 9.28, 9.22, 9.16, 9.16, 9.34, 8.68, 8.68, 8.62, 8.74, 8.86, 8.92, 8.86, 8.92, + 8.92, 11.74, 14.08, 11.98, 12.22, 12.04, 34.3, 10.36, 10.06, 9.64, 9.58, 9.58, 9.7, 9.7, 10.18, + 10.18, 10.18, 10.24, 10.06, 10.48, 9.94, 9.82, 9.76, 9.7, 8.02, 8.14, 8.14, 8.14, 8.08, 8.56, + 8.38, 8.38, 8.32, 8.38, 8.02, 8.02, 8.02, 8.14, 8.2, 7.9, 7.9, 7.84, 7.78, 7.66, 7.72, 7.72, + 7.72, 1.0, 1.0, 1.0, 1.0, 1.0, 1.45, 1.21, 1.21, 1.3, 1.15, 1.15, 1.15, 1.15, 1.15, 1.15, 1.15, + 1.33, 1.33, 1.33, 1.48, 1.3, 1.3, 1.3, 1.42, 1.09, 2.02, 1.81, 2.59, 2.59, 2.53, 1.21, 1.75, + 1.69, 1.66, 2.38, 1.57, 1.57, 1.57, 7.18, 7.18, 7.24, 7.24, 7.18, 7.12, 1.0, 1.0, 1.0, 1.0, 1.0, + 7.36, 7.3, 7.3, 7.18, 7.24, 7.06, 7.06, 7.06, 7.06, 7.0, 7.12, 7.12, 7.12, 7.0, 7.0, 7.0, 7.0, + 7.24, 7.24, 7.06, 7.18, 7.0, 7.06, 7.0, 7.0, 6.94, 7.0, 7.24, 1.03, 1.03, 1.03, 1.0, 1.0, 1.0, + 1.06, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.03, 1.03, 1.03, 1.03, 1.03, 1.03, 1.27, 1.27, 1.27, + 1.24, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.09, 1.09, 1.06, 1.06, 1.06, 1.06, 1.06, + 1.09, 1.09, 9.64, 9.58, 9.64, 9.64, 9.7, 9.7, 9.7, 9.7, 9.7, 9.64, 9.64, 9.64, 9.7, 1.03, 1.03, + 1.03, 1.03, 1.03, 1.03, 9.52, 1.0, 1.0, 9.4, 8.68, 8.68, 8.68, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, + 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 9.52, 9.46, 9.46, 9.46, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, + 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0 + ], + "peakHeightM": [ + 211.4, 209.9, 210.5, 210.2, 209.9, 209.7, 211.0, 212.1, 211.2, 210.6, 210.1, 209.4, 211.3, + 212.7, 211.6, 214.8, 215.8, 217.1, 217.7, 215.4, 212.5, 209.9, 207.4, 208.7, 209.9, 211.0, + 211.4, 209.5, 208.1, 207.5, 206.1, 205.1, 204.8, 204.6, 207.8, 207.2, 206.5, 206.6, 208.8, + 210.4, 210.1, 210.6, 211.2, 212.1, 213.0, 213.9, 214.2, 215.0, 214.8, 214.5, 212.9, 212.5, + 211.9, 211.2, 209.3, 207.3, 205.7, 205.8, 208.1, 209.6, 210.2, 209.9, 207.6, 208.2, 203.7, + 203.2, 207.2, 206.3, 204.2, 203.2, 204.0, 203.3, 203.0, 203.2, 205.3, 206.0, 204.7, 203.7, + 202.5, 201.5, 199.6, 198.3, 195.8, 192.2, 192.8, 198.3, 201.7, 202.3, 201.8, 194.9, 203.5, + 204.5, 207.7, 203.7, 199.7, 196.7, 193.3, 192.4, 196.5, 197.1, 197.2, 197.7, 197.5, 198.7, + 195.8, 197.0, 197.9, 198.4, 197.2, 198.2, 198.7, 199.6, 199.6, 197.9, 197.2, 198.1, 199.1, + 200.8, 200.1, 196.0, 192.5, 189.0, 190.8, 191.5, 192.3, 193.1, 193.4, 192.4, 191.7, 191.1, + 189.8, 190.8, 192.5, 193.0, 194.4, 195.2, 195.5, 195.1, 195.1, 195.4, 195.2, 195.5, 193.5, + 196.1, 195.9, 195.5, 195.6, 192.2, 192.5, 191.8, 190.5, 192.2, 194.0, 196.8, 195.7, 194.4, + 195.0, 193.7, 190.1, 191.0, 191.7, 192.0, 192.4, 192.8, 192.0, 191.0, 190.0, 191.8, 193.7, + 196.5, 195.6, 194.9, 194.5, 195.9, 197.3, 197.2, 196.1, 197.5, 198.2, 197.6, 196.9, 197.0, + 196.6, 196.7, 197.7, 197.5, 198.9, 199.5, 199.5, 200.9, 200.4, 201.1, 201.0, 198.9, 207.9, + 209.7, 208.9, 207.2, 204.3, 204.3, 208.1, 208.5, 208.1, 206.9, 209.1, 212.7, 213.5, 214.0, + 212.2, 213.2, 215.7, 217.3, 207.1, 204.6, 204.5, 208.8, 212.5, 213.9, 213.7, 209.3, 210.8, + 208.2, 210.9, 212.6, 213.0, 214.2, 213.0, 211.4, 210.3, 208.7, 209.5, 207.9, 207.4, 212.6, + 215.6, 216.4, 218.1, 217.5, 215.7, 214.9, 215.0, 214.9, 214.8, 214.4, 217.5, 218.3, 215.2, + 214.7, 209.3, 209.8, 208.9, 216.9, 218.4, 211.3, 211.8, 210.8, 214.4, 215.3, 216.4, 212.4, + 212.7, 213.1, 212.0, 212.6, 214.4, 215.0, 216.2, 206.4, 215.6, 216.9, 211.9, 213.6, 212.3, + 211.5, 206.5, 206.8, 207.2, 207.5, 207.2, 207.1, 207.8, 208.1, 210.0, 210.7, 210.2, 210.8, + 210.5, 217.2, 207.7, 207.3, 208.1, 209.5, 208.9, 209.9, 212.8, 213.1, 209.6, 210.4, 210.7, + 208.9, 207.3, 206.3, 206.4, 206.8, 207.2, 207.5, 208.9, 208.4, 213.2, 214.2, 215.2, 215.7, + 214.5, 211.2, 247.4, 209.9, 210.7, 259.2, 255.7, 244.2, 241.1, 242.6, 212.1, 236.3, 211.9, + 208.7, 209.9, 209.5, 234.2, 243.1, 238.7, 236.5, 241.0, 238.1, 211.5, 237.8, 234.9, 234.5, + 234.9, 226.3, 246.9, 227.9, 230.4, 259.6, 237.2, 237.1, 238.4, 243.8, 242.5, 239.9, 240.4, + 235.8, 240.4, 238.1, 241.4, 241.7, 242.3, 242.2, 240.6, 238.4, 247.1, 246.8, 245.2, 248.2, + 247.4, 245.4, 245.4, 242.7, 231.1, 234.0, 234.5, 233.4, 233.5, 231.9, 230.1, 242.5, 239.0, + 238.2, 233.0, 237.7, 232.3, 219.6, 219.9, 235.0, 235.9, 232.0, 228.4, 241.4, 237.8, 226.9, + 231.8, 230.5, 229.4, 227.0, 230.1, 229.5, 219.9, 226.9, 220.2, 221.3, 214.0, 214.8, 208.5, + 216.5, 217.9, 218.0, 217.9, 218.8, 554.9, 596.3, 639.3, 651.8, 655.9, 677.7, 654.8, 653.7, + 652.5, 660.3, 655.7, 632.9, 605.1, 578.6, 484.8, 496.7, 491.5, 237.4, 693.3, 682.9, 676.3, + 665.1, 672.6, 677.4, 699.5, 746.5, 793.0, 820.8, 836.9, 831.7, 813.8, 774.3, 513.7, 511.5, + 512.0, 493.9, 464.6, 457.9, 479.7, 526.0, 524.0, 515.7, 509.3, 531.0, 553.8, 538.4, 562.8, + 582.7, 541.4, 558.5, 545.3, 640.0, 583.8, 599.0, 593.8, 595.2, 603.6, 604.7, 620.9, 614.5, + 586.6, 570.3, 549.1, 511.8, 447.5, 462.2, 354.3, 358.3, 365.5, 371.3, 373.8, 377.0, 376.8, + 377.3, 377.6, 383.6, 372.4, 377.1, 382.3, 384.6, 386.4, 384.8, 382.9, 375.9, 365.9, 430.3, + 471.0, 427.2, 428.5, 403.2, 887.7, 380.7, 381.5, 382.0, 397.5, 401.8, 409.1, 418.8, 441.2, + 470.9, 490.1, 514.8, 516.4, 515.3, 490.4, 489.5, 486.6, 472.2, 410.8, 419.1, 428.3, 437.9, + 444.8, 466.6, 466.6, 465.3, 456.6, 449.4, 436.6, 430.0, 421.0, 419.8, 411.4, 398.0, 416.8, + 426.6, 425.1, 415.1, 407.2, 398.0, 385.9, 209.6, 209.3, 208.9, 208.5, 207.5, 216.6, 210.8, + 210.7, 213.0, 210.9, 211.9, 211.8, 211.8, 211.8, 211.6, 211.4, 216.1, 216.6, 216.7, 220.6, + 217.6, 218.4, 219.2, 224.9, 212.4, 237.7, 231.4, 251.4, 252.7, 252.0, 216.4, 231.7, 229.2, + 230.8, 250.7, 228.9, 228.5, 229.0, 397.4, 416.0, 438.4, 447.0, 439.3, 424.3, 218.7, 218.7, + 218.9, 218.8, 218.4, 445.6, 444.4, 455.8, 451.0, 454.8, 454.3, 455.3, 448.6, 443.6, 442.1, + 451.4, 458.2, 455.3, 452.1, 460.3, 460.0, 457.7, 467.1, 457.0, 454.1, 463.0, 459.5, 465.5, + 464.3, 456.8, 445.0, 425.5, 428.2, 218.8, 221.8, 222.7, 222.3, 223.1, 221.6, 223.5, 222.0, + 224.0, 225.4, 223.7, 222.5, 221.8, 220.9, 222.1, 224.0, 223.2, 221.6, 220.3, 218.8, 226.4, + 228.2, 225.4, 224.2, 216.9, 216.5, 217.1, 217.6, 217.9, 217.6, 217.0, 216.1, 216.6, 220.6, + 219.5, 218.9, 220.8, 221.5, 222.5, 220.2, 217.7, 216.4, 488.4, 497.5, 503.2, 515.2, 518.5, + 522.0, 535.7, 542.3, 542.3, 541.8, 536.7, 518.9, 493.5, 216.1, 217.8, 219.5, 220.5, 221.2, + 220.0, 516.0, 218.0, 218.4, 523.4, 502.5, 505.8, 494.9, 220.9, 221.3, 220.8, 220.4, 219.3, + 218.3, 217.5, 216.5, 215.5, 214.2, 213.0, 212.9, 213.3, 213.5, 473.5, 477.6, 487.5, 492.6, + 217.2, 219.2, 220.7, 220.7, 220.7, 218.9, 216.5, 214.4, 212.5, 210.6, 210.7, 210.9, 211.8, + 212.9, 213.7, 213.7, 213.7, 212.6 + ], + "slopeEast": [ + 0.002, -0.001, 0.056, 0.043, 0.022, 0.002, 0.007, -0.005, 0.014, 0.012, 0.002, -0.013, 0.046, + 0.027, -0.083, -0.005, -0.017, -0.01, -0.007, -0.03, -0.051, -0.056, -0.058, -0.088, -0.109, + -0.099, -0.085, -0.079, -0.077, 0.008, 0.005, 0.006, -0.01, 0.062, 0.068, 0.071, 0.074, 0.084, + 0.019, -0.001, 0.023, 0.02, 0.021, 0.024, 0.025, 0.031, 0.054, 0.031, 0.003, -0.022, -0.034, + -0.054, -0.055, -0.049, -0.047, -0.045, -0.038, -0.041, -0.051, -0.054, -0.073, -0.086, -0.104, + -0.05, -0.085, -0.083, 0.125, 0.082, 0.049, 0.018, 0.064, 0.034, -0.013, -0.004, -0.06, -0.087, + -0.128, -0.166, -0.129, -0.038, -0.154, -0.158, -0.163, -0.116, -0.12, 0.088, 0.055, 0.048, + 0.095, 0.036, 0.154, 0.016, -0.009, -0.018, -0.079, -0.121, -0.161, -0.174, 0.003, 0.037, 0.108, + 0.103, 0.1, 0.106, 0.101, -0.064, -0.045, 0.013, 0.149, 0.083, 0.078, 0.071, 0.039, -0.062, + -0.065, -0.118, -0.105, -0.072, -0.045, -0.025, -0.001, -0.008, -0.007, -0.02, -0.037, -0.058, + -0.078, -0.075, -0.069, -0.063, -0.054, -0.008, 0.003, 0.123, 0.036, 0.015, -0.005, 0.017, + 0.049, 0.086, 0.068, 0.013, 0.032, -0.024, -0.01, 0.005, 0.009, -0.005, -0.004, 0.021, 0.062, + -0.012, -0.014, -0.013, -0.031, 0.02, -0.004, -0.021, -0.005, -0.018, -0.026, -0.032, -0.038, + -0.046, -0.055, -0.063, -0.067, -0.007, 0.009, -0.048, -0.051, -0.051, -0.004, -0.015, -0.025, + -0.018, 0.002, -0.002, 0.005, 0.013, 0.02, 0.049, 0.013, 0.002, -0.023, -0.009, -0.075, -0.065, + 0.018, 0.02, 0.024, -0.023, -0.022, -0.049, 0.01, 0.056, -0.003, -0.001, 0.008, -0.048, -0.05, + -0.03, -0.015, 0.002, -0.045, -0.077, -0.052, -0.113, -0.035, 0.052, 0.029, -0.006, -0.008, + 0.119, 0.111, 0.049, 0.019, -0.001, -0.008, 0.003, 0.025, 0.028, 0.004, -0.059, 0.008, 0.005, + -0.0, 0.055, 0.03, -0.044, -0.024, -0.002, -0.029, -0.015, -0.028, -0.04, -0.079, -0.084, + -0.073, 0.118, 0.104, 0.095, 0.098, 0.122, -0.042, 0.014, 0.108, 0.117, -0.077, -0.066, -0.052, + 0.006, 0.018, -0.026, -0.008, 0.016, -0.019, -0.02, 0.006, 0.033, 0.056, 0.035, 0.017, 0.002, + 0.011, 0.009, -0.015, 0.039, -0.016, -0.019, 0.073, 0.06, 0.028, 0.012, -0.052, -0.037, -0.027, + -0.026, -0.025, -0.027, -0.034, -0.033, -0.112, -0.098, -0.018, -0.005, 0.014, -0.002, -0.012, + -0.021, -0.041, -0.06, -0.073, -0.068, -0.007, 0.023, -0.002, 0.014, 0.031, 0.042, 0.045, 0.05, + 0.06, 0.075, 0.066, 0.054, 0.029, 0.03, -0.066, -0.045, -0.033, -0.035, 0.1, 0.072, 0.022, + -0.017, 0.002, 0.006, 0.105, 0.007, -0.015, 0.014, -0.019, -0.045, 0.021, -0.027, -0.032, + -0.043, 0.023, -0.048, 0.051, -0.072, -0.099, -0.013, 0.016, 0.062, 0.05, 0.066, 0.041, 0.022, + -0.031, -0.028, -0.003, 0.047, -0.019, -0.007, 0.014, -0.047, 0.019, -0.023, -0.023, 0.008, + -0.064, 0.049, -0.035, -0.026, 0.01, 0.043, 0.057, 0.094, -0.076, -0.07, 0.043, 0.029, 0.051, + 0.001, 0.036, 0.056, -0.016, -0.042, -0.015, -0.0, 0.037, 0.052, 0.089, 0.032, 0.021, 0.06, + -0.018, 0.018, 0.002, 0.036, 0.058, -0.043, 0.005, -0.0, -0.004, -0.084, -0.088, 0.055, -0.004, + 0.02, 0.027, 0.018, -0.003, -0.013, -0.01, -0.005, 0.032, 0.037, -0.085, -0.114, 0.016, -0.076, + -0.081, 0.043, 0.029, 0.014, -0.247, -0.334, -0.183, -0.03, -0.023, 0.027, 0.067, 0.018, -0.17, + 0.04, 0.076, 0.16, 0.142, 0.214, -0.239, -0.036, 0.236, 0.006, -0.075, -0.015, -0.134, 0.191, + -0.098, -0.235, -0.094, -0.348, -0.156, -0.126, 0.06, -0.008, 0.039, 0.184, 0.048, 0.037, 0.035, + -0.057, -0.06, 0.062, -0.295, -0.071, -0.175, 0.046, -0.045, -0.173, -0.173, -0.161, -0.148, + 0.042, -0.034, -0.145, 0.247, 0.002, -0.209, 0.028, -0.129, -0.037, 0.154, -0.069, -0.023, + 0.149, 0.119, -0.146, 0.175, 0.112, -0.005, -0.029, -0.11, -0.064, -0.06, -0.018, -0.04, 0.03, + -0.087, -0.043, -0.015, -0.031, -0.13, -0.068, -0.06, 0.001, -0.021, -0.0, -0.013, 0.032, 0.012, + -0.025, 0.013, 0.081, 0.008, -0.101, 0.006, -0.063, -0.072, -0.034, -0.109, -0.03, -0.058, + -0.068, -0.174, -0.1, -0.092, -0.088, -0.053, 0.072, 0.007, -0.043, -0.029, -0.004, -0.033, + -0.07, -0.071, -0.07, -0.077, -0.07, -0.021, -0.02, -0.061, -0.004, -0.114, -0.02, -0.023, + 0.032, 0.05, -0.037, -0.005, -0.02, -0.109, -0.063, 0.014, -0.015, -0.037, 0.007, 0.019, 0.035, + 0.051, 0.064, -0.018, -0.034, -0.025, 0.038, -0.074, -0.071, -0.068, -0.065, -0.061, -0.056, + -0.05, -0.038, -0.032, -0.02, 0.035, -0.023, -0.034, -0.042, -0.009, -0.053, -0.061, -0.048, + 0.032, 0.055, -0.052, 0.019, 0.04, -0.026, 0.007, -0.033, -0.04, -0.044, -0.047, 0.134, -0.002, + 0.095, 0.061, -0.082, -0.127, 0.039, 0.057, 0.077, 0.092, 0.081, 0.007, -0.041, 0.057, -0.038, + 0.02, 0.004, -0.126, -0.118, -0.042, -0.061, -0.009, -0.034, -0.028, -0.016, 0.066, -0.008, + -0.033, -0.062, -0.168, 0.054, 0.058, -0.007, 0.007, -0.054, -0.042, -0.257, -0.152, 0.009, + 0.029, 0.032, 0.036, -0.033, -0.046, -0.05, 0.018, -0.059, -0.065, -0.078, -0.093, -0.109, + -0.126, -0.136, -0.072, -0.057, -0.05, -0.039, -0.032, -0.029, -0.024, -0.055, -0.05, -0.057, + 0.019, 0.031, 0.057, 0.078, 0.091, 0.084, 0.071, 0.05, 0.033, 0.066, 0.024, -0.039, -0.043, + -0.062, -0.086, -0.078, -0.035, -0.041, 0.114, -0.004, 0.08, -0.043, 0.008, 0.035, 0.117, 0.075, + 0.091, -0.088, -0.074, -0.157, -0.187, 0.023, 0.023, 0.021, 0.021, 0.017, 0.002, -0.01, 0.054, + 0.037, 0.016, 0.058, -0.096, -0.292, 0.009, 0.007, -0.003, -0.011, -0.009, -0.005, -0.017, + -0.031, -0.04, -0.029, -0.019, 0.007, 0.036, 0.064, 0.06, 0.091, 0.084, 0.021, 0.044, 0.024, + 0.007, 0.007, 0.006, -0.001, -0.004, -0.011, -0.026, -0.037, -0.048, -0.06, -0.057, -0.054, + -0.051, -0.031, -0.014, -0.007 + ], + "slopeNorth": [ + -0.005, 0.005, -0.003, 0.002, -0.006, -0.016, -0.044, -0.027, -0.024, -0.026, -0.026, -0.026, + -0.05, -0.054, 0.02, -0.042, -0.033, -0.022, -0.009, 0.008, 0.025, 0.032, 0.037, 0.003, 0.01, + 0.01, 0.013, 0.017, 0.026, 0.059, 0.018, -0.02, -0.039, 0.024, 0.024, 0.008, -0.005, -0.003, + -0.06, -0.088, -0.024, -0.033, -0.037, -0.039, -0.046, -0.052, -0.026, -0.019, -0.023, -0.025, + -0.027, -0.106, -0.113, -0.118, -0.111, -0.103, -0.016, -0.016, -0.129, -0.122, -0.096, -0.075, + -0.046, -0.071, 0.056, 0.061, -0.108, -0.077, 0.028, 0.067, -0.029, -0.009, 0.048, -0.009, + -0.029, -0.018, 0.006, 0.025, 0.144, 0.19, 0.17, 0.167, 0.149, -0.078, -0.083, -0.0, -0.081, + -0.04, -0.001, -0.033, -0.082, -0.031, 0.061, 0.14, 0.078, 0.132, 0.104, 0.127, -0.011, -0.01, + 0.071, 0.129, 0.113, 0.103, 0.13, -0.038, -0.037, 0.044, 0.052, 0.016, 0.01, -0.01, -0.02, + -0.014, 0.06, 0.086, 0.082, 0.067, 0.049, 0.051, 0.058, -0.08, -0.095, -0.099, -0.098, -0.093, + -0.064, -0.024, 0.007, 0.03, 0.041, -0.085, -0.064, -0.021, -0.069, -0.063, -0.054, -0.027, + 0.013, 0.058, 0.082, 0.091, 0.065, 0.04, 0.085, 0.117, 0.139, 0.134, 0.059, 0.047, 0.008, 0.002, + 0.005, 0.01, 0.034, 0.039, 0.068, 0.077, -0.041, -0.017, -0.011, -0.017, -0.021, -0.021, -0.003, + 0.016, 0.034, -0.057, -0.046, -0.067, -0.062, -0.059, -0.032, -0.028, -0.023, -0.01, -0.01, + -0.033, -0.028, -0.004, 0.019, -0.01, 0.056, 0.067, -0.061, -0.049, -0.108, -0.079, -0.0, 0.018, + 0.024, 0.053, 0.08, 0.085, -0.008, 0.015, 0.016, 0.027, 0.048, -0.05, -0.038, -0.058, -0.086, + -0.001, -0.061, -0.038, -0.014, 0.027, 0.062, -0.036, -0.007, 0.046, -0.023, -0.001, 0.01, + -0.073, 0.053, 0.073, -0.02, 0.004, -0.001, -0.014, -0.069, -0.016, -0.041, 0.001, 0.021, + -0.017, -0.019, -0.115, -0.11, -0.083, -0.119, -0.097, -0.076, -0.101, -0.069, -0.054, -0.022, + -0.045, -0.028, -0.016, -0.008, 0.019, -0.037, 0.005, 0.007, 0.01, -0.092, -0.096, -0.079, + 0.074, 0.07, -0.076, -0.025, -0.075, -0.039, 0.003, -0.006, 0.013, -0.03, -0.064, -0.079, + -0.076, -0.056, -0.026, 0.042, -0.084, 0.038, 0.055, 0.002, 0.033, 0.01, -0.003, 0.001, -0.02, + -0.041, -0.057, -0.056, -0.053, -0.045, -0.037, 0.043, 0.037, -0.023, -0.013, -0.012, 0.063, + 0.029, 0.024, 0.012, -0.007, 0.01, 0.006, -0.057, 0.007, -0.017, 0.02, 0.061, 0.058, 0.039, + 0.021, 0.001, -0.02, -0.025, -0.024, 0.039, 0.052, 0.03, 0.03, 0.028, 0.035, 0.003, 0.005, + 0.027, -0.048, -0.059, 0.005, -0.037, -0.029, -0.058, -0.066, -0.009, -0.02, 0.014, -0.009, + -0.015, -0.01, -0.045, -0.036, -0.085, -0.017, 0.011, -0.052, 0.048, -0.025, -0.024, -0.035, + -0.031, -0.05, -0.058, -0.013, -0.05, -0.053, -0.058, -0.033, -0.017, 0.001, -0.025, -0.066, + -0.023, -0.014, -0.011, -0.059, -0.019, -0.009, 0.005, -0.007, -0.034, -0.051, 0.002, 0.036, + 0.0, 0.007, -0.022, -0.028, -0.046, -0.051, -0.018, -0.021, -0.013, 0.012, -0.005, -0.035, + -0.036, 0.011, -0.065, 0.025, 0.006, 0.018, -0.103, 0.005, -0.007, -0.025, -0.061, -0.044, + -0.049, -0.008, 0.004, -0.043, 0.022, -0.062, -0.019, -0.055, 0.001, -0.01, -0.057, -0.005, + -0.051, -0.064, -0.023, 0.029, -0.044, 0.079, 0.066, -0.049, -0.043, 0.043, 0.037, 0.009, -0.04, + -0.175, -0.046, -0.025, -0.055, -0.045, 0.01, -0.078, -0.036, -0.127, -0.126, -0.145, 0.102, + 0.094, -0.123, 0.055, -0.109, 0.133, -0.142, -0.009, -0.085, 0.067, 0.024, 0.056, -0.06, 0.156, + 0.025, -0.068, 0.018, -0.106, 0.077, 0.019, -0.02, -0.043, 0.021, -0.021, 0.053, -0.023, -0.091, + -0.044, 0.016, 0.174, 0.024, 0.172, 0.213, -0.05, -0.021, -0.051, -0.255, 0.041, 0.076, -0.168, + 0.031, -0.153, -0.058, -0.022, 0.063, -0.084, -0.122, -0.153, -0.157, -0.171, 0.014, 0.054, + 0.036, 0.084, 0.009, 0.019, 0.018, -0.012, 0.013, 0.008, 0.011, 0.061, 0.061, 0.057, -0.013, + -0.036, -0.024, -0.045, -0.046, -0.101, -0.156, -0.066, -0.239, -0.1, -0.077, -0.243, -0.08, + 0.073, 0.039, 0.179, 0.106, 0.005, 0.086, 0.044, 0.321, 0.232, 0.195, 0.145, -0.074, -0.069, + -0.057, 0.021, -0.1, -0.165, 0.063, 0.139, 0.12, 0.091, 0.134, 0.082, 0.064, -0.056, -0.118, + -0.146, -0.004, -0.123, -0.142, -0.092, -0.121, 0.172, 0.209, 0.083, -0.15, -0.088, -0.128, + -0.152, -0.196, 0.004, -0.017, -0.027, -0.037, -0.042, 0.021, 0.014, 0.009, 0.003, 0.082, 0.074, + 0.061, 0.048, 0.031, 0.013, -0.006, 0.081, 0.074, 0.055, -0.036, 0.041, 0.024, 0.006, -0.068, + -0.022, 0.073, 0.021, 0.055, 0.009, 0.022, 0.079, -0.019, 0.052, 0.08, 0.02, 0.073, 0.04, 0.01, + 0.224, 0.291, 0.218, -0.011, -0.137, -0.234, 0.091, 0.094, 0.095, 0.093, 0.062, 0.02, 0.061, + 0.003, 0.044, -0.063, 0.145, -0.012, -0.046, -0.011, 0.075, 0.117, 0.046, -0.033, 0.108, 0.084, + -0.011, -0.094, -0.024, -0.13, 0.075, 0.1, 0.124, -0.018, -0.058, -0.136, -0.149, -0.227, + -0.022, 0.105, 0.119, 0.113, 0.09, 0.076, 0.045, -0.045, 0.032, 0.029, 0.034, 0.051, 0.055, + 0.044, 0.028, -0.072, -0.095, -0.088, -0.06, -0.032, -0.005, 0.003, -0.016, -0.008, 0.039, + 0.085, 0.076, 0.061, 0.046, 0.035, 0.031, 0.022, 0.018, 0.014, 0.006, 0.014, 0.009, -0.002, + -0.002, 0.003, -0.003, -0.024, -0.018, 0.068, 0.212, 0.049, 0.187, 0.06, 0.024, 0.003, -0.084, + -0.173, 0.03, -0.138, -0.17, -0.157, 0.006, -0.007, -0.026, -0.034, -0.038, -0.045, 0.001, + -0.075, -0.067, 0.237, 0.215, 0.109, 0.055, -0.068, -0.066, -0.059, -0.054, -0.059, -0.062, + -0.065, -0.07, -0.072, -0.064, -0.054, -0.045, -0.035, -0.022, 0.059, 0.161, 0.071, 0.021, + -0.021, -0.027, -0.033, -0.039, -0.044, -0.038, -0.029, -0.034, -0.057, -0.078, -0.075, -0.071, + -0.076, -0.085, -0.083, -0.057, -0.03, -0.018 + ] +} diff --git a/src/app/features/scene/ridge/weather.ts b/src/app/features/scene/ridge/weather.ts index 68ea88ea..edcb2ae3 100644 --- a/src/app/features/scene/ridge/weather.ts +++ b/src/app/features/scene/ridge/weather.ts @@ -43,7 +43,9 @@ export function weatherOf(value: number): Weather { } /** The page URL's `?weather=<0..1>`, when it is a number. */ -export function urlWeather(search: string = typeof location === 'undefined' ? '' : location.search): number | undefined { +export function urlWeather( + search: string = typeof location === 'undefined' ? '' : location.search +): number | undefined { const v = new URLSearchParams(search).get('weather'); if (v === null || v.trim() === '') return undefined; const n = Number(v); diff --git a/src/app/features/scene/ridge/weatherFeed.test.ts b/src/app/features/scene/ridge/weatherFeed.test.ts index e12bbefc..0f490cad 100644 --- a/src/app/features/scene/ridge/weatherFeed.test.ts +++ b/src/app/features/scene/ridge/weatherFeed.test.ts @@ -30,7 +30,9 @@ const NOW = new Date('2026-11-11T10:50:00Z'); describe('weather from the file', () => { it('credits GeoSphere as its legal page asks, and claims no licence', () => { - expect(WEATHER_ATTRIBUTION).toBe('Datenquelle: GeoSphere Austria - https://data.hub.geosphere.at'); + expect(WEATHER_ATTRIBUTION).toBe( + 'Datenquelle: GeoSphere Austria - https://data.hub.geosphere.at' + ); expect(WEATHER_ATTRIBUTION).not.toMatch(/CC/); }); it('rain is 0.8..1, by rate', () => { @@ -42,21 +44,41 @@ describe('weather from the file', () => { expect(clearSkyGhi(-5)).toBe(0); }); it('noon clear: radiation at the clear-sky value is no cloud', () => { - expect(weatherFromFile(file({ glow_wm2: clearSkyGhi(21), sunshine_s_10min: 600, humidity_pct: 95 }), 21)).toBe(0); + expect( + weatherFromFile( + file({ glow_wm2: clearSkyGhi(21), sunshine_s_10min: 600, humidity_pct: 95 }), + 21 + ) + ).toBe(0); expect(cloudFromGlow(300, 21)).toBeCloseTo(0.56, 1); // k = 0.9 is still a few oktas }); it('noon overcast: a fifth of the clear-sky radiation is nearly full cloud, and with saturated air, mist', () => { expect(cloudFromGlow(0.2 * clearSkyGhi(21), 21)).toBeGreaterThan(0.9); - expect(weatherFromFile(file({ glow_wm2: 0.2 * clearSkyGhi(21), humidity_pct: 80 }), 21)).toBeGreaterThan(0.6); - expect(weatherFromFile(file({ glow_wm2: 0.2 * clearSkyGhi(21), humidity_pct: 100 }), 21)).toBe(0.75); + expect( + weatherFromFile(file({ glow_wm2: 0.2 * clearSkyGhi(21), humidity_pct: 80 }), 21) + ).toBeGreaterThan(0.6); + expect(weatherFromFile(file({ glow_wm2: 0.2 * clearSkyGhi(21), humidity_pct: 100 }), 21)).toBe( + 0.75 + ); }); it('the radiation, not the humidity, decides by day: humid air under a bright sky is clear', () => { - expect(weatherFromFile(file({ glow_wm2: clearSkyGhi(30), sunshine_s_10min: 600, humidity_pct: 98 }), 30)).toBe(0); + expect( + weatherFromFile( + file({ glow_wm2: clearSkyGhi(30), sunshine_s_10min: 600, humidity_pct: 98 }), + 30 + ) + ).toBe(0); }); it('dusk: with the sun at 3 degrees the humidity ramp speaks, cloud 0 at 60% and 1 at 95%', () => { - expect(weatherFromFile(file({ glow_wm2: 5, sunshine_s_10min: 0, humidity_pct: 60 }), 3)).toBe(0); - expect(weatherFromFile(file({ glow_wm2: 5, sunshine_s_10min: 0, humidity_pct: 77.5 }), 3)).toBeCloseTo(0.35, 2); - expect(weatherFromFile(file({ glow_wm2: 5, sunshine_s_10min: 0, humidity_pct: 95 }), 3)).toBeCloseTo(0.7, 2); + expect(weatherFromFile(file({ glow_wm2: 5, sunshine_s_10min: 0, humidity_pct: 60 }), 3)).toBe( + 0 + ); + expect( + weatherFromFile(file({ glow_wm2: 5, sunshine_s_10min: 0, humidity_pct: 77.5 }), 3) + ).toBeCloseTo(0.35, 2); + expect( + weatherFromFile(file({ glow_wm2: 5, sunshine_s_10min: 0, humidity_pct: 95 }), 3) + ).toBeCloseTo(0.7, 2); }); it('night: the radiation and sunshine are 0.0 and the humidity speaks; saturated air is mist, 0.75', () => { expect(weatherFromFile(file({ glow_wm2: 0, humidity_pct: 40 }), -20)).toBe(0); @@ -68,21 +90,30 @@ describe('weather from the file', () => { for (const g of [56, 72, 89]) expect(cloudFromGlow(g, nov)).toBeGreaterThan(0.95); for (const g of [546, 569]) expect(cloudFromGlow(g, oct)).toBe(0); expect(weatherFromFile(file({ glow_wm2: 72, sunshine_s_10min: 0 }), nov)).toBeGreaterThan(0.65); - expect(weatherFromFile(file({ glow_wm2: 560, sunshine_s_10min: 600, humidity_pct: 70 }), oct)).toBe(0); + expect( + weatherFromFile(file({ glow_wm2: 560, sunshine_s_10min: 600, humidity_pct: 70 }), oct) + ).toBe(0); }); it('the sunshine cross-checks the radiation: agreement keeps the radiation, a half-sky disagreement splits it', () => { - expect(cloudEstimate(file({ glow_wm2: 0.2 * clearSkyGhi(21), sunshine_s_10min: 0 }), 21)).toBeGreaterThan(0.9); + expect( + cloudEstimate(file({ glow_wm2: 0.2 * clearSkyGhi(21), sunshine_s_10min: 0 }), 21) + ).toBeGreaterThan(0.9); // Radiation says clear, the sunshine says none: each is half trusted. expect(cloudEstimate(file({ glow_wm2: clearSkyGhi(21), sunshine_s_10min: 0 }), 21)).toBe(0.5); // No radiation reading by day: the sunshine speaks alone. expect(cloudEstimate(file({ glow_wm2: null, sunshine_s_10min: 300 }), 21)).toBe(0.5); expect(cloudEstimate(file({ glow_wm2: 0, sunshine_s_10min: 600 }), 21)).toBe(0); }); - it('with the sun below the instruments\' reach there is no estimate: the humidity speaks', () => { + it("with the sun below the instruments' reach there is no estimate: the humidity speaks", () => { expect(cloudEstimate(file({ glow_wm2: 3, sunshine_s_10min: 0 }), 3)).toBeNull(); }); it('null: unknown measurements are not zero, and nothing known is a clear sky', () => { - expect(weatherFromFile(file({ glow_wm2: null, humidity_pct: null, precip_mm_10min: null, sunshine_s_10min: null }), 21)).toBe(CLEAR_SKY); + expect( + weatherFromFile( + file({ glow_wm2: null, humidity_pct: null, precip_mm_10min: null, sunshine_s_10min: null }), + 21 + ) + ).toBe(CLEAR_SKY); expect(weatherFromFile(file({ glow_wm2: null, humidity_pct: 95 }), 21)).toBeCloseTo(0.7, 2); }); }); @@ -97,17 +128,26 @@ describe('the weather now', () => { expect(currentWeather(f, NOW)).toBe(CLEAR_SKY); }); it('the 30 minute mark itself is still fresh', () => { - expect(currentWeather(file({ glow_wm2: 0, humidity_pct: 100, fetched_at: '2026-11-11T10:20:00Z' }), NOW)).toBe(0.75); + expect( + currentWeather( + file({ glow_wm2: 0, humidity_pct: 100, fetched_at: '2026-11-11T10:20:00Z' }), + NOW + ) + ).toBe(0.75); }); it('falls back to a clear sky when the observation is more than 40 minutes old, though the file is fresh', () => { const f = file({ precip_mm_10min: 2, observed_at: '2026-11-11T10:09:00Z' }); expect(NOW.getTime() - Date.parse(f.observed_at)).toBeGreaterThan(OBSERVATION_STALE_MS); expect(currentWeather(f, NOW)).toBe(CLEAR_SKY); - expect(currentWeather(file({ precip_mm_10min: 2, observed_at: '2026-11-11T10:11:00Z' }), NOW)).toBeGreaterThan(0.8); + expect( + currentWeather(file({ precip_mm_10min: 2, observed_at: '2026-11-11T10:11:00Z' }), NOW) + ).toBeGreaterThan(0.8); }); it('falls back to a clear sky with no file, and with a fetch time from the future', () => { expect(currentWeather(null, NOW)).toBe(CLEAR_SKY); - expect(currentWeather(file({ precip_mm_10min: 2, fetched_at: '2026-11-12T10:40:00Z' }), NOW)).toBe(CLEAR_SKY); + expect( + currentWeather(file({ precip_mm_10min: 2, fetched_at: '2026-11-12T10:40:00Z' }), NOW) + ).toBe(CLEAR_SKY); }); }); @@ -117,23 +157,45 @@ describe('bad instrument values', () => { }); it('the radiation is set against the sun of the ten minutes it averaged, not the sun now', () => { // Sun rising: 07:45Z observation (mean of 07:35-07:45), read 35 minutes later at the stale limit. - const f = file({ observed_at: '2026-11-11T07:45:00Z', fetched_at: '2026-11-11T07:46:00Z', glow_wm2: 100, sunshine_s_10min: null, humidity_pct: 50 }); + const f = file({ + observed_at: '2026-11-11T07:45:00Z', + fetched_at: '2026-11-11T07:46:00Z', + glow_wm2: 100, + sunshine_s_10min: null, + humidity_pct: 50, + }); const at = (m: number) => currentWeather(f, new Date(Date.parse(f.observed_at) + m * 60_000)); expect(at(0)).toBe(at(29)); }); }); describe('reading weather.json', () => { - it('reads Eureka\'s file as the host writes it: Z times, a whole-station gap as nulls, 0.0 at night', () => { + it("reads Eureka's file as the host writes it: Z times, a whole-station gap as nulls, 0.0 at night", () => { const night = JSON.parse( '{"fetched_at":"2026-11-11T22:48:22Z","observed_at":"2026-11-11T22:40:00Z","station":11035,"precip_mm_10min":0.1,"humidity_pct":100.0,"sunshine_s_10min":0.0,"glow_wm2":0.0,"attribution":"Datenquelle: GeoSphere Austria - https://data.hub.geosphere.at"}' ); - expect(parseWeatherFile(night)).toMatchObject({ precip_mm_10min: 0.1, humidity_pct: 100, glow_wm2: 0, sunshine_s_10min: 0 }); - const gap = parseWeatherFile({ ...night, precip_mm_10min: null, humidity_pct: null, sunshine_s_10min: null, glow_wm2: null }); - expect(gap).toMatchObject({ precip_mm_10min: null, humidity_pct: null, sunshine_s_10min: null, glow_wm2: null }); + expect(parseWeatherFile(night)).toMatchObject({ + precip_mm_10min: 0.1, + humidity_pct: 100, + glow_wm2: 0, + sunshine_s_10min: 0, + }); + const gap = parseWeatherFile({ + ...night, + precip_mm_10min: null, + humidity_pct: null, + sunshine_s_10min: null, + glow_wm2: null, + }); + expect(gap).toMatchObject({ + precip_mm_10min: null, + humidity_pct: null, + sunshine_s_10min: null, + glow_wm2: null, + }); expect(currentWeather(gap, new Date('2026-11-11T22:50:00Z'))).toBe(CLEAR_SKY); }); - it('is not another station\'s file', () => { + it("is not another station's file", () => { expect(parseWeatherFile({ ...file(), station: 11036 })).toBeNull(); }); it('parses the agreed shape, keeping nulls and the sunshine it never reads', () => { @@ -146,15 +208,30 @@ describe('reading weather.json', () => { expect(parseWeatherFile(null)).toBeNull(); expect(parseWeatherFile('x')).toBeNull(); }); - it('rejects a time with no zone: it would be read in the client\'s local time', () => { + it("rejects a time with no zone: it would be read in the client's local time", () => { expect(parseWeatherFile({ ...file(), observed_at: '2026-11-10T10:40:00' })).toBeNull(); - expect(parseWeatherFile({ ...file(), observed_at: '2026-11-10T11:40:00+01:00' })).not.toBeNull(); + expect( + parseWeatherFile({ ...file(), observed_at: '2026-11-10T11:40:00+01:00' }) + ).not.toBeNull(); }); it('is no observation when the file is missing, fails, or is not JSON', async () => { - expect(await fetchWeatherFile('/weather.json', (async () => ({ ok: false })) as unknown as typeof fetch)).toBeNull(); - expect(await fetchWeatherFile('/weather.json', (async () => { throw new Error('offline'); }) as unknown as typeof fetch)).toBeNull(); expect( - await fetchWeatherFile('/weather.json', (async () => ({ ok: true, json: async () => { throw new Error('bad'); } })) as unknown as typeof fetch) + await fetchWeatherFile('/weather.json', (async () => ({ + ok: false, + })) as unknown as typeof fetch) + ).toBeNull(); + expect( + await fetchWeatherFile('/weather.json', (async () => { + throw new Error('offline'); + }) as unknown as typeof fetch) + ).toBeNull(); + expect( + await fetchWeatherFile('/weather.json', (async () => ({ + ok: true, + json: async () => { + throw new Error('bad'); + }, + })) as unknown as typeof fetch) ).toBeNull(); }); it('reads a good file', async () => { diff --git a/src/app/features/scene/ridge/weatherFeed.ts b/src/app/features/scene/ridge/weatherFeed.ts index 39379fd5..b2b4a7b0 100644 --- a/src/app/features/scene/ridge/weatherFeed.ts +++ b/src/app/features/scene/ridge/weatherFeed.ts @@ -34,7 +34,8 @@ const clamp01 = (v: number): number => Math.min(1, Math.max(0, v)); const num = (v: unknown): number | null => (typeof v === 'number' && Number.isFinite(v) ? v : null); /** An ISO time that names its zone (`Z` or an offset); `Date.parse` would read a bare one in the client's local time. */ -const isZonedTime = (v: unknown): v is string => typeof v === 'string' && /(Z|[+-]\d\d:?\d\d)$/.test(v) && !Number.isNaN(Date.parse(v)); +const isZonedTime = (v: unknown): v is string => + typeof v === 'string' && /(Z|[+-]\d\d:?\d\d)$/.test(v) && !Number.isNaN(Date.parse(v)); /** A `WeatherFile` from parsed JSON, or null when it does not have the shape (times missing or unparseable). */ export function parseWeatherFile(json: unknown): WeatherFile | null { @@ -96,8 +97,10 @@ export function cloudEstimate(f: WeatherFile, sunEl: number): number | null { export function weatherFromFile(f: WeatherFile, sunEl: number): number { const rain = f.precip_mm_10min ?? 0; if (rain > 0) return clamp01(0.8 + 0.2 * Math.min(1, rain / 1.5)); - const cloud = cloudEstimate(f, sunEl) ?? (f.humidity_pct === null ? 0 : clamp01((f.humidity_pct - 60) / 35)); - const mist = f.humidity_pct === null || cloud < 0.5 ? 0 : clamp01((f.humidity_pct - 92) / 8) * 0.75; + const cloud = + cloudEstimate(f, sunEl) ?? (f.humidity_pct === null ? 0 : clamp01((f.humidity_pct - 60) / 35)); + const mist = + f.humidity_pct === null || cloud < 0.5 ? 0 : clamp01((f.humidity_pct - 92) / 8) * 0.75; return Math.max(0.7 * cloud, mist); } @@ -106,13 +109,24 @@ export function currentWeather(f: WeatherFile | null, now: Date = new Date()): n if (!f) return CLEAR_SKY; const fetched = now.getTime() - Date.parse(f.fetched_at); const observed = now.getTime() - Date.parse(f.observed_at); - if (!(fetched <= STALE_MS && fetched >= -SKEW_MS && observed <= OBSERVATION_STALE_MS && observed >= -SKEW_MS)) return CLEAR_SKY; + if ( + !( + fetched <= STALE_MS && + fetched >= -SKEW_MS && + observed <= OBSERVATION_STALE_MS && + observed >= -SKEW_MS + ) + ) + return CLEAR_SKY; // GLOW and SO are means of the ten minutes ending at observed_at: the sun of their midpoint is the one to set them against. return weatherFromFile(f, viennaSun(new Date(Date.parse(f.observed_at) - 5 * 60_000)).elevation); } /** Fetch and parse `weather.json`; null on any failure. */ -export async function fetchWeatherFile(url: string, fetcher: typeof fetch = fetch): Promise { +export async function fetchWeatherFile( + url: string, + fetcher: typeof fetch = fetch +): Promise { try { const res = await fetcher(url, { cache: 'no-store' }); return res.ok ? parseWeatherFile(await res.json()) : null; diff --git a/src/app/features/scene/shaderSource.ts b/src/app/features/scene/shaderSource.ts index 42631d57..6c7d7efd 100644 --- a/src/app/features/scene/shaderSource.ts +++ b/src/app/features/scene/shaderSource.ts @@ -37,5 +37,7 @@ export function fragmentSource(stage: Stage, defines = ''): string { const erosion = solid && WORLD.phacelleGradBound > 0 ? phacelle : ''; const world = solid ? sdf : ''; const math = trimGlsl(cultmath, [erosion, common, world, stages[stage]]); - return `${HEADER}${defines}${worldDefines()}\n${math}\n${erosion}\n${common}\n${world}\n${stages[stage]}`; + return `${HEADER}${defines}${worldDefines()}\n${math}\n${erosion}\n${common}\n${world}\n${ + stages[stage] + }`; } diff --git a/src/app/features/scene/skyForState.test.ts b/src/app/features/scene/skyForState.test.ts index 7c8e14b1..a04ac173 100644 --- a/src/app/features/scene/skyForState.test.ts +++ b/src/app/features/scene/skyForState.test.ts @@ -88,7 +88,13 @@ describe('ridgeSkyFor', () => { }); it('draws the sky auto resolves to, never nothing', () => { - const key = (s: ReturnType) => [s.top, s.mid, s.horizon, s.band, s.physical?.sun]; + const key = (s: ReturnType) => [ + s.top, + s.mid, + s.horizon, + s.band, + s.physical?.sun, + ]; setSkyClock(() => atStandardTime(12, 0)); expect(key(ridgeSkyFor('auto', 0.3))).toEqual(key(ridgeSkyFor('day', 0.3))); setSkyClock(() => atStandardTime(0, 0)); diff --git a/src/app/features/scene/skyInk.test.ts b/src/app/features/scene/skyInk.test.ts index 888744b3..c92892f6 100644 --- a/src/app/features/scene/skyInk.test.ts +++ b/src/app/features/scene/skyInk.test.ts @@ -7,10 +7,11 @@ import type { FixedSkyState } from './skyBand'; import { LIGHTEST_PATCH, NIGHT_INK_MAX_L, - NOTICE_GROUND, + SCRIM_INK, + SCRIM_GROUND, inkForState, labelInk, - noticeInk, + scrimStyle, sceneInk, userColorInk, } from './skyInk'; @@ -182,27 +183,24 @@ describe('labelInk', () => { }); }); -describe('noticeInk', () => { - const ratio = (a: number, b: number) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); +describe('scrimStyle', () => { + // The scrim over the lightest possible ground (white) is the worst case the ink can meet. + const [r, g, b, a] = (SCRIM_GROUND.match(/[\d.]+/g) ?? []).map(Number); + const worst = chroma.rgb( + ...([r, g, b].map((c) => c * a + 255 * (1 - a)) as [number, number, number]) + ); + const ratio = (x: number, y: number) => (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); - it('is light enough to clear AA on the dark ridge wherever the theme ink does not', () => { - (['day', 'dusk'] as FixedSkyState[]).forEach((state) => { - const ink = noticeInk({ state, phone: true }) as string; - expect(ratio(luminance(ink), NOTICE_GROUND)).toBeGreaterThanOrEqual(AA); - // the dark theme ink it replaces does not - expect( - ratio(luminance(baked.noonday.tokens.SurfaceVariant.OnContainer), NOTICE_GROUND) - ).toBeLessThan(AA); - }); + it('clears AA for its ink over its scrim even on a white ground', () => { + expect(ratio(luminance(SCRIM_INK), luminance(worst.hex()))).toBeGreaterThanOrEqual(AA); }); - it('leaves night (already light) and every desktop state to the theme', () => { - expect(noticeInk({ state: 'night', phone: true })).toBeUndefined(); - (['day', 'dusk', 'night'] as FixedSkyState[]).forEach((state) => - expect(noticeInk({ state, phone: false })).toBeUndefined() + it('is the same in every sky state and viewport while a scene runs, and absent without one', () => { + const styles = (['day', 'dusk', 'night'] as FixedSkyState[]).flatMap((state) => + [true, false].map((phone) => scrimStyle({ state, phone })) ); - expect( - ratio(luminance(baked.midnight.tokens.SurfaceVariant.OnContainer), NOTICE_GROUND) - ).toBeGreaterThanOrEqual(AA); + styles.forEach((x) => expect(x).toEqual(styles[0])); + expect(styles[0]).toMatchObject({ color: SCRIM_INK, backgroundColor: SCRIM_GROUND }); + expect(scrimStyle(undefined)).toBeUndefined(); }); }); diff --git a/src/app/features/scene/skyInk.ts b/src/app/features/scene/skyInk.ts index b7a3667f..b8e38f31 100644 --- a/src/app/features/scene/skyInk.ts +++ b/src/app/features/scene/skyInk.ts @@ -95,18 +95,22 @@ export const labelInk = ({ state, phone }: SkyInk): string | undefined => state === 'dusk' && phone ? '#29221a' : undefined; /** - * WCAG relative luminance of the lightest pixel under the notice line above the composer on a phone: the dark - * foreground ridge, the same in every state (measured 2026-11-10 from the Starfire phone shots at 390x844, - * ratatoskr-dev-preview/phone-room-{day,dusk,night}.png, text region x 15-355 CSS px: median 0.0052, 90th percentile - * 0.0089, rounded up to 0.01). The labelInk patches above are the lightest terrain behind the timeline; here the - * ground is dark, so the ink must be light. + * The training-notice line above the composer sits on whatever the scene draws there: the dark ridge on a phone in + * the Starfire shots, but a mid-tone ridge at other widths and the near-black foreground ridge on a desktop, so no + * ink chosen for one ground holds on the others (measured 2026-11-10 on the pretend-server line, ratatoskr-dev-preview/ + * taps2-REPORT.md: phone day 3.08, dusk 3.97 with light ink; desktop day 1.01, dusk 1.22 with the theme's dark ink). + * So the line carries its own ground: light ink on a dark scrim, in every sky state, on every viewport, whatever + * the scene is behind it. The scrim is composited over the lightest ground there is (white); the ink over that + * worst case clears AA (skyInk.test.ts). */ -export const NOTICE_GROUND = 0.01; +export const SCRIM_INK = baked.midnight.tokens.SurfaceVariant.OnContainer; +export const SCRIM_GROUND = 'rgba(10, 14, 24, 0.82)'; /** - * The ink of the training-notice line above the composer where the theme's own does not clear AA there. On a phone the - * line sits on the dark ridge in every state, but Noonday (day, dusk) wears dark ink, 1.2:1 at dusk by the numbers - * above; Midnight's (night) is already light and stays (undefined). Desktop is unmeasured and stays. + * The style of a line of text that must be read whatever the scene draws behind it (the training notice, the desk's + * recorded rulings): its own ground and ink while a scene runs, or undefined without one. */ -export const noticeInk = ({ state, phone }: SkyInk): string | undefined => - phone && state !== 'night' ? baked.midnight.tokens.SurfaceVariant.OnContainer : undefined; +export const scrimStyle = (ink: SkyInk | undefined) => + ink + ? { color: SCRIM_INK, backgroundColor: SCRIM_GROUND, borderRadius: 4, paddingInline: 6 } + : undefined; diff --git a/src/app/features/scene/terrain/terrainAlbedo.ts b/src/app/features/scene/terrain/terrainAlbedo.ts index 48dc2b42..fdbd9360 100644 --- a/src/app/features/scene/terrain/terrainAlbedo.ts +++ b/src/app/features/scene/terrain/terrainAlbedo.ts @@ -157,10 +157,8 @@ export function grainAt( distance: number ): number { const w = triWeights(n); - const over = ( - k: ReadonlyArray, - f: typeof waves - ) => w[0] * f(k, z, y) + w[1] * f(k, x, z) + w[2] * f(k, x, y); + const over = (k: ReadonlyArray, f: typeof waves) => + w[0] * f(k, z, y) + w[1] * f(k, x, z) + w[2] * f(k, x, y); const coarse = over(COARSE, waves); const fine = over(FINE, wavesWarped) * (1 - smooth(ALBEDO.fineNear, ALBEDO.fineFar, distance)); const layers = Math.sin(y * 0.9 + wobbleAt(x, y, z, n) * 3); diff --git a/src/app/pages/auth/AuthFooter.tsx b/src/app/pages/auth/AuthFooter.tsx index 2b888f55..305df247 100644 --- a/src/app/pages/auth/AuthFooter.tsx +++ b/src/app/pages/auth/AuthFooter.tsx @@ -4,7 +4,7 @@ import * as css from './styles.css'; export function AuthFooter() { return ( - + About diff --git a/src/app/pages/auth/login/PasswordLoginForm.tsx b/src/app/pages/auth/login/PasswordLoginForm.tsx index 7934bc9f..57a82daa 100644 --- a/src/app/pages/auth/login/PasswordLoginForm.tsx +++ b/src/app/pages/auth/login/PasswordLoginForm.tsx @@ -36,6 +36,7 @@ import { import { PasswordInput } from '../../../components/password-input'; import { FieldError } from '../FiledError'; import { loginErrorMessage } from '../authMessages'; +import { ForgotLink } from '../styles.css'; import { getResetPasswordPath } from '../../pathUtils'; import { stopPropagation } from '../../../utils/keyboard'; @@ -251,7 +252,9 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog )} - Forget Password? + + Forget Password? + diff --git a/src/app/pages/auth/styles.css.ts b/src/app/pages/auth/styles.css.ts index e762207b..f8a7a95b 100644 --- a/src/app/pages/auth/styles.css.ts +++ b/src/app/pages/auth/styles.css.ts @@ -54,6 +54,17 @@ export const AuthFooter = style({ padding: config.space.S200, }); +// The footer's links: 16 px apart, and on a phone 22 px between rows, so each link's 11 px of hit area above and +// below meets its neighbour's and does not cross it (see the tap-target rules at the foot of this file). +export const AuthFooterLinks = style({ + gap: config.space.S400, + '@media': { [`screen and (max-width: ${MOBILE_BREAKPOINT}px)`]: { rowGap: 22 } }, +}); + +// Forget Password? sits 5 px under the password field and 16 px above Login: on a phone its hit area takes the 5 and +// grows 7 px of real layout so the 44 reach neither. +export const ForgotLink = style({}); + // While the background scene runs (html[data-scene], set by features/scene/BackgroundScene), the // signed-out ground is clear and the card is a pane shaped like a message bubble (BubbleContent: // radius R500, no border), filled with the sky's bubble colour. The scene is one fixed dusk for @@ -134,6 +145,17 @@ globalStyle(`${scene} ${AuthFooter} a`, { // back out of the layout, so nothing moves and nothing is drawn. An inline link ignores the margin and its padding // never moves a line, which is the same result. const SCREEN_PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`; +globalStyle(`${AuthCard} a.${ForgotLink}`, { + '@media': { + [SCREEN_PHONE]: { + display: 'inline-block', + paddingTop: 5, + paddingBottom: 23, + marginTop: -5, + marginBottom: -16, + }, + }, +}); globalStyle(`${AuthCard} a`, { '@media': { [SCREEN_PHONE]: { paddingBlock: 14, marginBlock: -14 } }, }); diff --git a/src/app/phoneTapTargets.test.ts b/src/app/phoneTapTargets.test.ts index 57a40bfc..996a7556 100644 --- a/src/app/phoneTapTargets.test.ts +++ b/src/app/phoneTapTargets.test.ts @@ -9,11 +9,13 @@ const emitted = async (file: string) => { const filePath = `${process.cwd()}/src/app/${file}`; const { source } = await compile({ filePath, cwd: process.cwd(), identOption: 'debug' }); const code = await processVanillaFile({ source, filePath, identOption: 'debug' }); - const bytes = Buffer.from( - (code.match(/source=#?([A-Za-z0-9+/=]+)/) as RegExpMatchArray)[1], - 'base64' - ); - return (bytes[0] === 0x1f && bytes[1] === 0x8b ? zlib.gunzipSync(bytes) : bytes).toString(); + // A file with recipes inlines one source per rule group; read them all. + return [...code.matchAll(/source=#?([A-Za-z0-9+/=]+)/g)] + .map((m) => { + const bytes = Buffer.from(m[1], 'base64'); + return (bytes[0] === 0x1f && bytes[1] === 0x8b ? zlib.gunzipSync(bytes) : bytes).toString(); + }) + .join('\n'); }; type Decls = Record; @@ -42,37 +44,55 @@ const rulesFor = (css: string, selector: RegExp, media: string | undefined): Dec const PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`; const TAP = 44; -// [target, css file, selector, measured height in CSS px on the Starfire phone run (390x844)]. -const targets: [string, string, RegExp, number][] = [ +// [target, css file, selector, measured height in CSS px on the Starfire phone run (390x844), free space measured +// above and below the target before its neighbour's box (taps2-REPORT.md): the hit area may reach out of the layout +// box (negative margin) by no more than that, or it takes taps from its neighbour. Space it needs beyond that is +// real layout growth, which the rules above take from the padding the margin does not give back. +const targets: [string, string, RegExp, number, number, number][] = [ [ 'sender-name button', 'components/message/layout/layout.css.ts', /^button\.layout_Username__/, 23, + 11, + 11, ], [ 'room-topic button', 'features/room/RoomViewHeader.css.ts', /RoomViewHeader_HeaderTopic__\w+$/, 18, + 0, + 26, ], - ['sign-in card links', 'pages/auth/styles.css.ts', /styles_AuthCard__\w+ a$/, 16], - ['sign-in footer links', 'pages/auth/styles.css.ts', /styles_AuthFooter__\w+ a$/, 22], + ['sign-in card links', 'pages/auth/styles.css.ts', /styles_AuthCard__\w+ a$/, 16, 14, 14], + [ + 'forget-password link', + 'pages/auth/styles.css.ts', + /styles_AuthCard__\w+ a\.styles_ForgotLink__\w+$/, + 16, + 5, + 16, + ], + // The footer's rows are 22 px apart on a phone (AuthFooterLinks), 11 px for each side of the gap. + ['sign-in footer links', 'pages/auth/styles.css.ts', /styles_AuthFooter__\w+ a$/, 22, 11, 11], ]; -describe.each(targets)('phone tap target: %s', (_name, file, selector, height) => { +describe.each(targets)('phone tap target: %s', (_name, file, selector, height, above, below) => { it(`grows its hit area to ${TAP} px without moving layout`, async () => { const css = await emitted(file); const [phone] = rulesFor(css, selector, PHONE); expect(phone, 'a phone rule exists').toBeDefined(); - const top = phone['padding-top'] ?? phone['padding-block']; - const bottom = phone['padding-bottom'] ?? phone['padding-block']; - const marginTop = phone['margin-top'] ?? phone['margin-block']; - const marginBottom = phone['margin-bottom'] ?? phone['margin-block']; + const top = phone['padding-top'] ?? phone['padding-block'] ?? 0; + const bottom = phone['padding-bottom'] ?? phone['padding-block'] ?? 0; + const marginTop = phone['margin-top'] ?? phone['margin-block'] ?? 0; + const marginBottom = phone['margin-bottom'] ?? phone['margin-block'] ?? 0; expect(height + top + bottom).toBeGreaterThanOrEqual(TAP); - // the margin takes back exactly what the padding added, so the target does not move or push its neighbours - expect(marginTop).toBe(-top); - expect(marginBottom).toBe(-bottom); + // the hit area reaches out of the layout box by no more than the free space on that side + expect(-marginTop).toBeLessThanOrEqual(above); + expect(-marginBottom).toBeLessThanOrEqual(below); + expect(marginTop).toBeLessThanOrEqual(0); + expect(marginBottom).toBeLessThanOrEqual(0); }); it('leaves every other screen as it was: no padding or margin outside the phone query', async () => { @@ -82,3 +102,17 @@ describe.each(targets)('phone tap target: %s', (_name, file, selector, height) = ); }); }); + +describe('phone navigator rows', () => { + // The links and buttons inside a row are 44 px (EstateNavigator.css.ts); a row shorter than that lets them spill + // onto the next row (the 38 px pitch of taps2-REPORT.md), so the row and the section header are 44 too. + it('are as tall as their tap targets', async () => { + const css = await emitted('components/nav/styles.css.ts'); + const phone = [ + ...css.matchAll(/@media screen and \(max-width: 750px\) \{\n([\s\S]*?\n)\}(?:\n|$)/g), + ] + .map((m) => m[1]) + .join('\n'); + expect(phone.match(/min-height: 2\.75rem/g)?.length).toBeGreaterThanOrEqual(2); + }); +});