daycards strip
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
import UnlockDialog from './UnlockDialog.svelte';
|
||||
import { PREMIUM_PERKS, PREMIUM_PRICE, SIGNUP_URL } from './config';
|
||||
import { PREMIUM_PERKS, SIGNUP_URL, getPremiumPrice } from './config';
|
||||
import { isPremium, premiumState, refreshPremium } from './premium';
|
||||
|
||||
interface Props {
|
||||
@@ -14,6 +14,7 @@
|
||||
let { feature = 'This page', children }: Props = $props();
|
||||
|
||||
let unlockOpen = $state(false);
|
||||
const price = getPremiumPrice();
|
||||
|
||||
// Re-verify the stored key whenever the gate mounts.
|
||||
onMount(refreshPremium);
|
||||
@@ -53,7 +54,7 @@
|
||||
|
||||
<h2 class="text-xl font-bold tracking-tight">{feature} is a premium feature</h2>
|
||||
<p class="mx-auto mt-1.5 max-w-sm text-sm text-muted-foreground">
|
||||
Support this open-source weather project and unlock the extras from {PREMIUM_PRICE}.
|
||||
Support this open-source weather project and unlock the extras from {price}.
|
||||
</p>
|
||||
|
||||
<ul class="mx-auto mt-5 grid max-w-sm gap-2 text-left">
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
|
||||
import { PREMIUM_PRICE, SIGNUP_URL } from './config';
|
||||
import { SIGNUP_URL, getPremiumPrice } from './config';
|
||||
import { clearLicense, isPremium, premiumState, storedLicenseKey, verifyKey } from './premium';
|
||||
|
||||
interface Props {
|
||||
@@ -15,6 +15,8 @@
|
||||
|
||||
let { open = $bindable(false) }: Props = $props();
|
||||
|
||||
const price = getPremiumPrice();
|
||||
|
||||
// Prefill with the stored key so an existing subscriber sees their key.
|
||||
let keyInput = $state('');
|
||||
let submitting = $state(false);
|
||||
@@ -129,7 +131,7 @@
|
||||
rel="noopener"
|
||||
class="font-semibold text-primary underline-offset-2 hover:underline"
|
||||
>
|
||||
Subscribe from {PREMIUM_PRICE}
|
||||
Subscribe from {price}
|
||||
</a>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
@@ -19,8 +19,46 @@ export const PAYWALL_API_BASE = stripTrailingSlash(
|
||||
/** Where prospective subscribers go to sign up (the paywall repo's signup form). */
|
||||
export const SIGNUP_URL = env.VITE_PAYWALL_SIGNUP_URL ?? `${PAYWALL_API_BASE}/`;
|
||||
|
||||
/** Display price, shown on the paywall panel. */
|
||||
export const PREMIUM_PRICE = env.VITE_PREMIUM_PRICE ?? '€3 / month';
|
||||
// ─── Location-based pricing ──────────────────────────────────────────────────
|
||||
// The signup form charges 3 in the visitor's currency (EUR / USD / CHF), picked
|
||||
// from their location. Mirror that here so the paywall copy matches. Detection
|
||||
// is timezone/locale based (no network geo lookup) and guarded for SSR.
|
||||
|
||||
const CURRENCY_SYMBOL: Record<string, string> = { EUR: '€', USD: '$', CHF: 'CHF' };
|
||||
const US_ZONES =
|
||||
/^America\/(New_York|Detroit|Chicago|Denver|Boise|Phoenix|Los_Angeles|Anchorage|Adak|Juneau|Sitka|Nome|Yakutat|Menominee|Indiana|Kentucky|North_Dakota)/;
|
||||
|
||||
export type PremiumCurrency = 'EUR' | 'USD' | 'CHF';
|
||||
|
||||
export function detectCurrency(): PremiumCurrency {
|
||||
if (typeof Intl === 'undefined') return 'EUR';
|
||||
let tz = '';
|
||||
try {
|
||||
tz = Intl.DateTimeFormat().resolvedOptions().timeZone || '';
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (/Zurich|Vaduz/.test(tz)) return 'CHF';
|
||||
let region = '';
|
||||
try {
|
||||
if (typeof navigator !== 'undefined') {
|
||||
region = new Intl.Locale(navigator.language).maximize().region || '';
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (region === 'CH') return 'CHF';
|
||||
if (region === 'US' || US_ZONES.test(tz)) return 'USD';
|
||||
return 'EUR';
|
||||
}
|
||||
|
||||
/** Display price for the paywall panel, e.g. "€3 / month" or "CHF 3 / month". */
|
||||
export function getPremiumPrice(): string {
|
||||
if (env.VITE_PREMIUM_PRICE) return env.VITE_PREMIUM_PRICE;
|
||||
const currency = detectCurrency();
|
||||
const symbol = CURRENCY_SYMBOL[currency];
|
||||
return currency === 'CHF' ? `${symbol} 3 / month` : `${symbol}3 / month`;
|
||||
}
|
||||
|
||||
/** Short, human list of what premium unlocks (shown on the locked panel). */
|
||||
export const PREMIUM_PERKS = [
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
import { defaultParameters } from '../../options';
|
||||
import DailyCards from './DailyCards.svelte';
|
||||
import DailyStripSticky from './DailyStripSticky.svelte';
|
||||
import HourlyTable from './HourlyTable.svelte';
|
||||
import MeteogramCharts from './MeteogramCharts.svelte';
|
||||
import ModelSelector from './ModelSelector.svelte';
|
||||
@@ -199,6 +200,8 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Desktop: the full day cards -->
|
||||
<div class="hidden md:block">
|
||||
<DailyCards
|
||||
daily={fetchedDaily}
|
||||
{selectedDay}
|
||||
@@ -209,6 +212,25 @@
|
||||
canExtendPast={pastDays < 3}
|
||||
onExtendPast={() => (pastDays = 3)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: a compact day strip and the hourly table share this wrapper, so
|
||||
the strip stays stuck (collapsing as it goes) while scrolling the table
|
||||
and then releases exactly at the table's bottom, freeing the meteograms.
|
||||
The strip itself is hidden on md+ (the desktop cards above take over). -->
|
||||
<div>
|
||||
{#if fetchedDaily}
|
||||
<DailyStripSticky
|
||||
daily={fetchedDaily}
|
||||
{selectedDay}
|
||||
units={params}
|
||||
onSelectDay={switchDay}
|
||||
canExtend={forecastDays < 15}
|
||||
onExtend={() => (forecastDays = 15)}
|
||||
canExtendPast={pastDays < 3}
|
||||
onExtendPast={() => (pastDays = 3)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if fetchedHourly && fetchedDaily}
|
||||
<HourlyTable
|
||||
@@ -223,9 +245,10 @@
|
||||
<!-- placeholder with the table's approximate height: no layout shift -->
|
||||
<div
|
||||
transition:fade={{ duration: 200 }}
|
||||
class="h-[430px] animate-pulse rounded-2xl border border-border/70 bg-card"
|
||||
class="h-107.5 animate-pulse rounded-2xl border border-border/70 bg-card"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if fetchedHourly}
|
||||
<MeteogramCharts data={fetchedHourly} {selectedDay} units={params} {loading} />
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
<script lang="ts">
|
||||
import { onMount, tick } from 'svelte';
|
||||
|
||||
import { formatZoned, getRelativeDayLabel, isSameDayInZone } from '$lib/utils/date';
|
||||
|
||||
import { getTempStyle } from '../../utils/colors';
|
||||
import { getWeatherIconName } from '../../utils/weather-codes';
|
||||
import { type FetchedDaily, type WeatherUnits } from './types';
|
||||
|
||||
interface Props {
|
||||
daily: FetchedDaily | null;
|
||||
selectedDay: Date;
|
||||
units: WeatherUnits;
|
||||
onSelectDay: (date: Date, index: number) => void;
|
||||
canExtend?: boolean;
|
||||
onExtend?: () => void;
|
||||
canExtendPast?: boolean;
|
||||
onExtendPast?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
daily,
|
||||
selectedDay,
|
||||
units,
|
||||
onSelectDay,
|
||||
canExtend = false,
|
||||
onExtend,
|
||||
canExtendPast = false,
|
||||
onExtendPast
|
||||
}: Props = $props();
|
||||
|
||||
// ─── Scroll-driven collapse (full → compact) ────────────────────────────────
|
||||
// 0 = full cards (at the top of the page), 1 = compact square strip (stuck).
|
||||
// The progress sentinel sits ABOVE the sticky strip, so the strip shrinking
|
||||
// (which reflows the table BELOW it) never feeds back into the measurement.
|
||||
let progress = $state(0);
|
||||
let sentinelEl = $state<HTMLDivElement>();
|
||||
let stripScrollEl = $state<HTMLDivElement>();
|
||||
let daysWrapEl = $state<HTMLDivElement>();
|
||||
// Resolved in onMount (client only) so this never touches `window` during the
|
||||
// prerender of city pages.
|
||||
let scrollParent: HTMLElement | Window | null = $state(null);
|
||||
|
||||
const clamp = (v: number, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, v));
|
||||
const lerp = (a: number, b: number) => a + (b - a) * progress;
|
||||
|
||||
function findScrollParent(el: HTMLElement | null): HTMLElement | Window {
|
||||
let node = el?.parentElement ?? null;
|
||||
while (node) {
|
||||
const oy = getComputedStyle(node).overflowY;
|
||||
if (oy === 'auto' || oy === 'scroll') return node;
|
||||
node = node.parentElement;
|
||||
}
|
||||
return window;
|
||||
}
|
||||
|
||||
// Distance (px) over which the collapse plays out once the strip sticks.
|
||||
const RANGE = 120;
|
||||
let ticking = false;
|
||||
|
||||
function measure() {
|
||||
ticking = false;
|
||||
if (!sentinelEl || !scrollParent) return;
|
||||
const topRef = scrollParent instanceof Window ? 0 : scrollParent.getBoundingClientRect().top;
|
||||
const top = sentinelEl.getBoundingClientRect().top - topRef;
|
||||
progress = clamp(-top / RANGE);
|
||||
}
|
||||
|
||||
function onScroll() {
|
||||
if (ticking) return;
|
||||
ticking = true;
|
||||
requestAnimationFrame(measure);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
scrollParent = findScrollParent(sentinelEl ?? null);
|
||||
const target: EventTarget = scrollParent;
|
||||
target.addEventListener('scroll', onScroll, { passive: true });
|
||||
window.addEventListener('resize', onScroll, { passive: true });
|
||||
measure();
|
||||
return () => {
|
||||
target.removeEventListener('scroll', onScroll);
|
||||
window.removeEventListener('resize', onScroll);
|
||||
};
|
||||
});
|
||||
|
||||
// Re-measure once data (and therefore the strip height) changes.
|
||||
$effect(() => {
|
||||
void daily;
|
||||
tick().then(measure);
|
||||
});
|
||||
|
||||
// Start scrolled so the "Past" button sits just off the left edge (revealed by
|
||||
// scrolling left), with the first day flush to the content edge — same as the
|
||||
// desktop cards. Runs once per dataset.
|
||||
let scrolledForRef: FetchedDaily | null = null;
|
||||
$effect(() => {
|
||||
const d = daily;
|
||||
if (!d || !canExtendPast || !stripScrollEl || !daysWrapEl || scrolledForRef === d) return;
|
||||
scrolledForRef = d;
|
||||
const scroll = stripScrollEl;
|
||||
const wrap = daysWrapEl;
|
||||
requestAnimationFrame(() => {
|
||||
// 12px = the strip's px-3 left padding, so the first day lands on the edge
|
||||
scroll.scrollLeft +=
|
||||
wrap.getBoundingClientRect().left - scroll.getBoundingClientRect().left - 12;
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Derived sizing ─────────────────────────────────────────────────────────
|
||||
const CELL_W = 64;
|
||||
// Full height fits the whole stack (weekday, label, icon, temps, precip+wind)
|
||||
// without clipping; the horizontal scroll container also clips vertically, so
|
||||
// the content must fit inside the cell.
|
||||
let cellH = $derived(lerp(134, 64));
|
||||
let iconSize = $derived(lerp(40, 20));
|
||||
// Top padding eases from pt-1.5 (6px) when full to pt-0.5 (2px) when compact.
|
||||
let padTop = $derived(lerp(6, 1));
|
||||
// The secondary rows collapse (height + opacity together) faster than the
|
||||
// overall shrink, so the cell reaches a clean square before it stops shrinking.
|
||||
let detailFactor = $derived(clamp(1 - progress * 1.6));
|
||||
let relFactor = $derived(clamp(1 - progress * 2));
|
||||
// Bar background/divider/shadow fade in promptly once it starts collapsing.
|
||||
let chromeOpacity = $derived(clamp(progress / 0.35));
|
||||
</script>
|
||||
|
||||
<!-- progress sentinel: 0-height marker just above the sticky strip -->
|
||||
<div bind:this={sentinelEl} aria-hidden="true"></div>
|
||||
|
||||
<div class="daystrip sticky -top-3 z-30 -mx-3 md:hidden" style="--chrome:{chromeOpacity}">
|
||||
<div class="flex gap-1.5 overflow-x-auto px-3 py-2" bind:this={stripScrollEl}>
|
||||
{#if daily}
|
||||
{#if canExtendPast && onExtendPast}
|
||||
<button
|
||||
type="button"
|
||||
class="strip-side flex shrink-0 flex-col items-center justify-center rounded-xl border border-dashed border-border/70 text-muted-foreground"
|
||||
style="width:{CELL_W}px;height:{cellH}px"
|
||||
onclick={onExtendPast}
|
||||
aria-label="Load recent past days"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.75"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 6l-6 6 6 6" />
|
||||
</svg>
|
||||
<span class="text-[9px] leading-tight font-semibold">Past</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-1.5" bind:this={daysWrapEl}>
|
||||
{#each daily.dailyDates as time, index (index)}
|
||||
{@const selected = isSameDayInZone(time, selectedDay, daily.timezone)}
|
||||
{@const tempMax = daily.daily.temperature_2m_max[index]}
|
||||
{@const tempMin = daily.daily.temperature_2m_min[index]}
|
||||
{@const wCode = daily.daily.weather_code[index]}
|
||||
{@const precipSum = daily.daily.precipitation_sum[index]}
|
||||
{@const windMax = daily.daily.windspeed_10m_max[index]}
|
||||
{@const maxStyle = getTempStyle(tempMax, String(units.temperature_unit))}
|
||||
{#if tempMax != null && !isNaN(tempMax) && !(tempMax === 0 && tempMin === 0)}
|
||||
<button
|
||||
type="button"
|
||||
class="strip-cell flex shrink-0 cursor-pointer flex-col items-center justify-start gap-0.5 rounded-xl border px-1 pb-1.5 {selected
|
||||
? 'border-primary bg-primary/10 ring-1 ring-primary/50'
|
||||
: 'border-border/60 bg-card'}"
|
||||
style="width:{CELL_W}px;height:{cellH}px;padding-top:{padTop}px"
|
||||
aria-pressed={selected}
|
||||
onclick={() => onSelectDay(time, index)}
|
||||
>
|
||||
<span
|
||||
class="text-[11px] font-semibold tracking-wide {selected ? 'text-primary' : ''}"
|
||||
>
|
||||
{formatZoned(time, daily.timezone, 'EEE').toUpperCase()}
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="overflow-hidden text-[9px] leading-none text-muted-foreground"
|
||||
style="opacity:{relFactor};max-height:{relFactor * 14}px"
|
||||
>
|
||||
{getRelativeDayLabel(time, daily.timezone)}
|
||||
</span>
|
||||
|
||||
<div class="relative">
|
||||
<svg class="fill-foreground" width="{iconSize}px" height="{iconSize}px">
|
||||
<use
|
||||
xlink:href="/images/weather-icons/{getWeatherIconName(wCode, true)}.svg#Layer_1"
|
||||
></use>
|
||||
</svg>
|
||||
<!-- night companion icon, present in the full view, fades as it collapses -->
|
||||
<svg
|
||||
class="absolute -right-1 -bottom-0.5 rounded-full bg-card fill-foreground/60 p-px ring-1 ring-border/60"
|
||||
width="{iconSize * 0.44}px"
|
||||
height="{iconSize * 0.44}px"
|
||||
style="opacity:{relFactor}"
|
||||
>
|
||||
<use
|
||||
xlink:href="/images/weather-icons/{getWeatherIconName(
|
||||
wCode,
|
||||
false
|
||||
)}.svg#Layer_1"
|
||||
></use>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="flex items-baseline gap-1 leading-none">
|
||||
<span
|
||||
class="rounded-md px-1.5 py-0.5 text-[12px] font-extrabold tabular-nums"
|
||||
style="background-color:{maxStyle.bg};color:{maxStyle.fg}"
|
||||
>
|
||||
{tempMax.toFixed(0)}°
|
||||
</span>
|
||||
<span class="text-[11px] font-semibold tabular-nums text-muted-foreground">
|
||||
{tempMin.toFixed(0)}°
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex w-full flex-col items-center gap-0.5 overflow-hidden text-[10px] tabular-nums text-muted-foreground"
|
||||
style="opacity:{detailFactor};max-height:{detailFactor * 42}px"
|
||||
>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<svg class="fill-sky-500" width="13" height="13">
|
||||
<use xlink:href="/images/weather-icons/wi-raindrop.svg#Layer_1"></use>
|
||||
</svg>
|
||||
{Number(precipSum ?? 0).toFixed(precipSum >= 10 ? 0 : 1)}
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<svg class="fill-muted-foreground" width="13" height="13">
|
||||
<use xlink:href="/images/weather-icons/wi-strong-wind.svg#Layer_1"></use>
|
||||
</svg>
|
||||
{windMax?.toFixed(0) ?? '-'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if canExtend && onExtend}
|
||||
<button
|
||||
type="button"
|
||||
class="strip-side flex shrink-0 flex-col items-center justify-center rounded-xl border border-dashed border-border/70 text-muted-foreground"
|
||||
style="width:{CELL_W}px;height:{cellH}px"
|
||||
onclick={onExtend}
|
||||
aria-label="Load the longer-range forecast"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.75"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 5v14m-7-7h14" />
|
||||
</svg>
|
||||
<span class="text-[9px] leading-tight font-semibold">15d</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* The bar background/divider/shadow fade in only once the strip has collapsed
|
||||
into its compact form, so at the top of the page it reads as plain cards. */
|
||||
.daystrip {
|
||||
background: color-mix(
|
||||
in oklab,
|
||||
var(--color-background) calc(var(--chrome) * 100%),
|
||||
transparent
|
||||
);
|
||||
backdrop-filter: blur(calc(var(--chrome) * 6px));
|
||||
border-bottom: 1px solid
|
||||
color-mix(in oklab, var(--color-border) calc(var(--chrome) * 100%), transparent);
|
||||
box-shadow: 0 6px 12px -8px rgba(0, 0, 0, calc(var(--chrome) * 0.35));
|
||||
}
|
||||
.strip-cell,
|
||||
.strip-side {
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
background-color 0.15s;
|
||||
}
|
||||
.daystrip :global(.overflow-x-auto) {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
@@ -42,13 +42,19 @@
|
||||
>
|
||||
<Select.Trigger
|
||||
aria-label="{label} selection"
|
||||
class="group h-auto min-h-14 min-w-0 flex-1 cursor-pointer gap-3 rounded-xl border-2 border-primary/35 bg-card py-2 ps-2.5 shadow-sm transition-colors hover:border-primary/70 hover:shadow-md data-[size=default]:h-auto data-[state=open]:border-primary sm:min-w-72 sm:flex-none"
|
||||
class="group h-auto min-h-12 min-w-0 flex-1 cursor-pointer gap-2.5 rounded-xl border-2 border-primary/35 bg-card py-1.5 ps-2.5 shadow-sm transition-colors hover:border-primary/70 hover:shadow-md data-[size=default]:h-auto data-[state=open]:border-primary sm:min-h-14 sm:gap-3 sm:py-2 sm:min-w-72 sm:flex-none"
|
||||
>
|
||||
<div
|
||||
class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/12 text-primary"
|
||||
class="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/12 text-primary sm:size-9"
|
||||
>
|
||||
<!-- layered-globe icon: weather model -->
|
||||
<svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.75">
|
||||
<svg
|
||||
class="size-4.5 sm:size-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.75"
|
||||
>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
@@ -60,9 +66,14 @@
|
||||
<span class="text-[11px] font-semibold tracking-wide text-primary uppercase">
|
||||
{label}
|
||||
</span>
|
||||
<span class="max-w-full truncate text-sm font-bold text-foreground">{modelLabel}</span>
|
||||
<span class="max-w-full truncate text-[13px] font-bold text-foreground sm:text-sm"
|
||||
>{modelLabel}</span
|
||||
>
|
||||
{#if modelMeta}
|
||||
<span class="max-w-full truncate text-[11px] leading-tight text-muted-foreground">
|
||||
<!-- the meta line is dropped on mobile to keep the trigger compact -->
|
||||
<span
|
||||
class="hidden max-w-full truncate text-[11px] leading-tight text-muted-foreground sm:block"
|
||||
>
|
||||
{modelMeta}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
@@ -496,8 +496,9 @@ export function buildPanelDef(
|
||||
unit: leftKind ? unitForKind(leftKind, units) : '',
|
||||
unitRight: rightKind ? unitForKind(rightKind, units) : undefined,
|
||||
yMin: leftKind && isZeroBased(leftKind) ? 0 : undefined,
|
||||
// pressure sits far from zero, so its axis is derived from the data
|
||||
zeroBaseLeft: leftKind !== 'pressure',
|
||||
// temperature and pressure sit far from zero, so their axis is derived from
|
||||
// the data range (a forced 0 baseline just wastes vertical space)
|
||||
zeroBaseLeft: leftKind ? isZeroBased(leftKind) : true,
|
||||
yMinRight: rightKind && rightZero ? 0 : undefined,
|
||||
yMaxRight: rightKind === 'percent' ? 100 : undefined,
|
||||
hasPictograms: allDefs.some((d) => d.pictograms),
|
||||
|
||||
Reference in New Issue
Block a user