LGTM
This commit is contained in:
parent
701c17a703
commit
f114ada255
44 changed files with 5264 additions and 1674 deletions
|
|
@ -54,8 +54,12 @@ services:
|
||||||
init: true
|
init: true
|
||||||
build: /volumes/syncthing/Projects/property-map/screenshot
|
build: /volumes/syncthing/Projects/property-map/screenshot
|
||||||
environment:
|
environment:
|
||||||
|
PORT: "8002"
|
||||||
APP_URL: http://frontend:3001
|
APP_URL: http://frontend:3001
|
||||||
CACHE_DIR: /cache
|
CACHE_DIR: /cache
|
||||||
|
SCREENSHOT_CONCURRENCY: "3"
|
||||||
|
SCREENSHOT_RATE_WINDOW_MS: "60000"
|
||||||
|
SCREENSHOT_RATE_LIMIT: "30"
|
||||||
volumes:
|
volumes:
|
||||||
- screenshot-cache:/cache
|
- screenshot-cache:/cache
|
||||||
networks:
|
networks:
|
||||||
|
|
|
||||||
9
frontend/public/home-hex-pattern.svg
Normal file
9
frontend/public/home-hex-pattern.svg
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="138" height="159" viewBox="0 0 138 159">
|
||||||
|
<g fill="none" stroke="#14b8a6" stroke-width="1.2" stroke-linejoin="round" stroke-opacity="0.5">
|
||||||
|
<path d="M46 0L69 40L46 80H0L-23 40L0 0Z"/>
|
||||||
|
<path d="M46 80L69 120L46 159H0L-23 120L0 80Z"/>
|
||||||
|
<path d="M115 40L138 80L115 120H69L46 80L69 40Z"/>
|
||||||
|
<path d="M184 0L207 40L184 80H138L115 40L138 0Z"/>
|
||||||
|
<path d="M184 80L207 120L184 159H138L115 120L138 80Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 472 B |
Binary file not shown.
|
Before Width: | Height: | Size: 238 KiB After Width: | Height: | Size: 355 KiB |
Binary file not shown.
|
|
@ -52,6 +52,10 @@ function PageFallback() {
|
||||||
return <div className="flex-1 bg-warm-50 dark:bg-navy-950" />;
|
return <div className="flex-1 bg-warm-50 dark:bg-navy-950" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function unavailableAuthAction(): never {
|
||||||
|
throw new Error('Authentication actions are not available in this render mode');
|
||||||
|
}
|
||||||
|
|
||||||
function pageToPath(page: Page, inviteCode?: string): string {
|
function pageToPath(page: Page, inviteCode?: string): string {
|
||||||
switch (page) {
|
switch (page) {
|
||||||
case 'dashboard':
|
case 'dashboard':
|
||||||
|
|
@ -80,7 +84,10 @@ function pageToPath(page: Page, inviteCode?: string): string {
|
||||||
case 'account':
|
case 'account':
|
||||||
return '/account';
|
return '/account';
|
||||||
case 'invite':
|
case 'invite':
|
||||||
return `/invite/${inviteCode || ''}`;
|
if (!inviteCode) {
|
||||||
|
throw new Error('Cannot build invite path without an invite code');
|
||||||
|
}
|
||||||
|
return `/invite/${inviteCode}`;
|
||||||
default:
|
default:
|
||||||
return '/';
|
return '/';
|
||||||
}
|
}
|
||||||
|
|
@ -190,7 +197,7 @@ export default function App() {
|
||||||
setShowLicenseSuccess(true);
|
setShowLicenseSuccess(true);
|
||||||
}
|
}
|
||||||
// Always refresh auth on startup to pick up server-side subscription changes
|
// Always refresh auth on startup to pick up server-side subscription changes
|
||||||
refreshAuth().catch(() => {});
|
refreshAuth().catch(() => { });
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const savedSearches = useSavedSearches(user?.id ?? null);
|
const savedSearches = useSavedSearches(user?.id ?? null);
|
||||||
|
|
@ -263,7 +270,9 @@ export default function App() {
|
||||||
window.history.replaceState(
|
window.history.replaceState(
|
||||||
{ page: activePage },
|
{ page: activePage },
|
||||||
'',
|
'',
|
||||||
pageToPath(activePage) + window.location.search + window.location.hash
|
pageToPath(activePage, inviteCode ?? undefined) +
|
||||||
|
window.location.search +
|
||||||
|
window.location.hash
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const handlePopState = (e: PopStateEvent) => {
|
const handlePopState = (e: PopStateEvent) => {
|
||||||
|
|
@ -275,7 +284,6 @@ export default function App() {
|
||||||
setPendingInfoFeature(e.state.infoFeature);
|
setPendingInfoFeature(e.state.infoFeature);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fall back to deriving page from pathname
|
|
||||||
const parsed = pathToPage(window.location.pathname);
|
const parsed = pathToPage(window.location.pathname);
|
||||||
page = parsed?.page || 'home';
|
page = parsed?.page || 'home';
|
||||||
setActivePage(page);
|
setActivePage(page);
|
||||||
|
|
@ -326,9 +334,9 @@ export default function App() {
|
||||||
user={null}
|
user={null}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
screenshotMode
|
screenshotMode
|
||||||
onLoginClick={() => {}}
|
onLoginClick={unavailableAuthAction}
|
||||||
onRegisterClick={() => {}}
|
onRegisterClick={unavailableAuthAction}
|
||||||
onLicenseGranted={() => {}}
|
onLicenseGranted={unavailableAuthAction}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|
@ -340,19 +348,21 @@ export default function App() {
|
||||||
<MapPage
|
<MapPage
|
||||||
features={features}
|
features={features}
|
||||||
poiCategoryGroups={poiCategoryGroups}
|
poiCategoryGroups={poiCategoryGroups}
|
||||||
initialFilters={urlState.filters || {}}
|
initialFilters={urlState.filters}
|
||||||
initialViewState={initialViewState}
|
initialViewState={initialViewState}
|
||||||
initialPOICategories={urlState.poiCategories || new Set()}
|
initialPOICategories={urlState.poiCategories}
|
||||||
initialTab={urlState.tab || 'area'}
|
initialTab={urlState.tab}
|
||||||
initialLoading={initialLoading}
|
initialLoading={initialLoading}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
pendingInfoFeature={null}
|
pendingInfoFeature={null}
|
||||||
onClearPendingInfoFeature={() => {}}
|
onClearPendingInfoFeature={() => { }}
|
||||||
onNavigateTo={() => {}}
|
onNavigateTo={() => { }}
|
||||||
screenshotMode
|
screenshotMode
|
||||||
ogMode={isOgMode}
|
ogMode={isOgMode}
|
||||||
initialTravelTime={urlState.travelTime}
|
initialTravelTime={urlState.travelTime}
|
||||||
shareCode={urlState.share}
|
shareCode={urlState.share}
|
||||||
|
onLoginClick={unavailableAuthAction}
|
||||||
|
onRegisterClick={unavailableAuthAction}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|
@ -365,8 +375,7 @@ export default function App() {
|
||||||
onPageChange={navigateTo}
|
onPageChange={navigateTo}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
onToggleTheme={toggleTheme}
|
onToggleTheme={toggleTheme}
|
||||||
onExport={exportState?.onExport ?? null}
|
exportState={activePage === 'dashboard' ? exportState : null}
|
||||||
exporting={exportState?.exporting ?? false}
|
|
||||||
onSaveSearch={activePage === 'dashboard' && user ? () => setShowSaveModal(true) : null}
|
onSaveSearch={activePage === 'dashboard' && user ? () => setShowSaveModal(true) : null}
|
||||||
savingSearch={savedSearches.saving}
|
savingSearch={savedSearches.saving}
|
||||||
user={user}
|
user={user}
|
||||||
|
|
@ -452,10 +461,10 @@ export default function App() {
|
||||||
<MapPage
|
<MapPage
|
||||||
features={features}
|
features={features}
|
||||||
poiCategoryGroups={poiCategoryGroups}
|
poiCategoryGroups={poiCategoryGroups}
|
||||||
initialFilters={mapUrlState.filters || {}}
|
initialFilters={mapUrlState.filters}
|
||||||
initialViewState={initialViewState}
|
initialViewState={initialViewState}
|
||||||
initialPOICategories={mapUrlState.poiCategories || new Set()}
|
initialPOICategories={mapUrlState.poiCategories}
|
||||||
initialTab={mapUrlState.tab || 'area'}
|
initialTab={mapUrlState.tab}
|
||||||
initialLoading={initialLoading}
|
initialLoading={initialLoading}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
pendingInfoFeature={pendingInfoFeature}
|
pendingInfoFeature={pendingInfoFeature}
|
||||||
|
|
|
||||||
|
|
@ -36,14 +36,19 @@ function generateHexes(): HexConfig[] {
|
||||||
export default function HexCanvas({
|
export default function HexCanvas({
|
||||||
isDark = false,
|
isDark = false,
|
||||||
animated = true,
|
animated = true,
|
||||||
|
className = '',
|
||||||
}: {
|
}: {
|
||||||
isDark?: boolean;
|
isDark?: boolean;
|
||||||
animated?: boolean;
|
animated?: boolean;
|
||||||
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const hexes = useMemo(generateHexes, []);
|
const hexes = useMemo(generateHexes, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 overflow-hidden pointer-events-none" style={{ zIndex: 0 }}>
|
<div
|
||||||
|
className={`absolute inset-0 overflow-hidden pointer-events-none ${className}`.trim()}
|
||||||
|
style={{ zIndex: 0 }}
|
||||||
|
>
|
||||||
{hexes.map((hex, i) => (
|
{hexes.map((hex, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ import { DualHistogram } from '../map/DualHistogram';
|
||||||
import { FeatureLabel } from '../ui/FeatureLabel';
|
import { FeatureLabel } from '../ui/FeatureLabel';
|
||||||
import { Slider } from '../ui/Slider';
|
import { Slider } from '../ui/Slider';
|
||||||
import type { TravelTimeEntry } from '../../hooks/useTravelTime';
|
import type { TravelTimeEntry } from '../../hooks/useTravelTime';
|
||||||
import { PARTY_FEATURE_COLORS } from '../../lib/consts';
|
import { PARTY_FEATURE_COLORS, STACKED_SEGMENT_COLORS } from '../../lib/consts';
|
||||||
import { formatValue } from '../../lib/format';
|
import { formatValue } from '../../lib/format';
|
||||||
import type {
|
import type {
|
||||||
FeatureMeta,
|
FeatureMeta,
|
||||||
|
|
@ -47,6 +47,13 @@ import type {
|
||||||
|
|
||||||
const SHOWCASE_STEP_COUNT = 4;
|
const SHOWCASE_STEP_COUNT = 4;
|
||||||
const SHOWCASE_INTERVAL_MS = 5200;
|
const SHOWCASE_INTERVAL_MS = 5200;
|
||||||
|
const SHOWCASE_SCOUT_INTERVAL_MS = 9000;
|
||||||
|
const SHOWCASE_STEP_INTERVALS_MS = [
|
||||||
|
SHOWCASE_INTERVAL_MS,
|
||||||
|
SHOWCASE_INTERVAL_MS,
|
||||||
|
SHOWCASE_INTERVAL_MS,
|
||||||
|
SHOWCASE_SCOUT_INTERVAL_MS,
|
||||||
|
];
|
||||||
const FILTER_ANIMATION_MS = 5000;
|
const FILTER_ANIMATION_MS = 5000;
|
||||||
const INSPECT_SCROLL_ANIMATION_MS = 4600;
|
const INSPECT_SCROLL_ANIMATION_MS = 4600;
|
||||||
const SCOUT_TABLE_REVEAL_MS = 2400;
|
const SCOUT_TABLE_REVEAL_MS = 2400;
|
||||||
|
|
@ -781,18 +788,6 @@ function RightPaneOnlyScreen({
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-hidden bg-white dark:bg-navy-900/60 dark:backdrop-blur-sm">
|
<div className="h-full overflow-hidden bg-white dark:bg-navy-900/60 dark:backdrop-blur-sm">
|
||||||
<div className="flex h-full flex-col overflow-hidden">
|
<div className="flex h-full flex-col overflow-hidden">
|
||||||
<div className="bg-white px-3 py-3 shadow-sm dark:bg-navy-900/65 sm:px-5 sm:py-4">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-teal-50 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300 sm:h-9 sm:w-9">
|
|
||||||
<MapPinIcon className="h-4 w-4 sm:h-5 sm:w-5" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="text-base font-black leading-tight text-navy-950 dark:text-warm-100 sm:text-lg">
|
|
||||||
{t('home.showcaseStep3HeaderArea')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
ref={scrollerRef}
|
ref={scrollerRef}
|
||||||
className="min-h-0 flex-1 space-y-2 overflow-y-auto p-2.5 scrollbar-hide sm:space-y-3 sm:p-4"
|
className="min-h-0 flex-1 space-y-2 overflow-y-auto p-2.5 scrollbar-hide sm:space-y-3 sm:p-4"
|
||||||
|
|
@ -844,7 +839,11 @@ function RightPaneOnlyScreen({
|
||||||
<ChartBarIcon className="h-4 w-4 text-teal-600 dark:text-teal-400" />
|
<ChartBarIcon className="h-4 w-4 text-teal-600 dark:text-teal-400" />
|
||||||
<span>{t('home.showcaseStep3Stat2Label')}</span>
|
<span>{t('home.showcaseStep3Stat2Label')}</span>
|
||||||
</div>
|
</div>
|
||||||
<StackedBarChart segments={CRIME_SEGMENTS} total={82} />
|
<StackedBarChart
|
||||||
|
segments={CRIME_SEGMENTS}
|
||||||
|
total={82}
|
||||||
|
colorMap={STACKED_SEGMENT_COLORS}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg bg-warm-50 p-3 dark:bg-navy-950/50 sm:p-4">
|
<div className="rounded-lg bg-warm-50 p-3 dark:bg-navy-950/50 sm:p-4">
|
||||||
<div className="mb-2 flex items-center justify-between gap-3">
|
<div className="mb-2 flex items-center justify-between gap-3">
|
||||||
|
|
@ -1015,7 +1014,7 @@ function ScoutScreen({ isActive }: { isActive: boolean }) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative z-10 mt-3 shrink-0 rounded-lg bg-navy-950/55 p-3 text-center text-white shadow-2xl shadow-navy-950/20 backdrop-blur-sm sm:mt-4 sm:p-4">
|
<div className="relative z-10 mt-3 shrink-0 rounded-lg bg-navy-950/55 p-3 text-left text-white shadow-2xl shadow-navy-950/20 backdrop-blur-sm sm:mt-4 sm:p-4">
|
||||||
<div className="text-base font-black leading-tight">
|
<div className="text-base font-black leading-tight">
|
||||||
{t('home.showcaseStep4Conclusion')}
|
{t('home.showcaseStep4Conclusion')}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1025,7 +1024,7 @@ function ScoutScreen({ isActive }: { isActive: boolean }) {
|
||||||
'Test the commute from a real front door, not a borough name.',
|
'Test the commute from a real front door, not a borough name.',
|
||||||
'Compare viewings with evidence already in hand.',
|
'Compare viewings with evidence already in hand.',
|
||||||
].map((item) => (
|
].map((item) => (
|
||||||
<div key={item} className="flex justify-center gap-2">
|
<div key={item} className="flex gap-2">
|
||||||
<CheckIcon className="mt-0.5 h-4 w-4 shrink-0 text-teal-300" />
|
<CheckIcon className="mt-0.5 h-4 w-4 shrink-0 text-teal-300" />
|
||||||
<span>{item}</span>
|
<span>{item}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1040,21 +1039,23 @@ function ScoutScreen({ isActive }: { isActive: boolean }) {
|
||||||
function DashboardShowcase({
|
function DashboardShowcase({
|
||||||
activeStep,
|
activeStep,
|
||||||
active,
|
active,
|
||||||
|
hasStarted,
|
||||||
inspectUserScrolledRef,
|
inspectUserScrolledRef,
|
||||||
}: {
|
}: {
|
||||||
activeStep: number;
|
activeStep: number;
|
||||||
active: ShowcaseStep;
|
active: ShowcaseStep;
|
||||||
|
hasStarted: boolean;
|
||||||
inspectUserScrolledRef: MutableRefObject<boolean>;
|
inspectUserScrolledRef: MutableRefObject<boolean>;
|
||||||
}) {
|
}) {
|
||||||
const screens = [
|
const screens = [
|
||||||
<FilterOnlyScreen key="filter" isActive={activeStep === 0} />,
|
<FilterOnlyScreen key="filter" isActive={hasStarted && activeStep === 0} />,
|
||||||
<EnglandHexMapScreen key="match" isActive={activeStep === 1} />,
|
<EnglandHexMapScreen key="match" isActive={hasStarted && activeStep === 1} />,
|
||||||
<RightPaneOnlyScreen
|
<RightPaneOnlyScreen
|
||||||
key="inspect"
|
key="inspect"
|
||||||
isActive={activeStep === 2}
|
isActive={hasStarted && activeStep === 2}
|
||||||
userScrolledRef={inspectUserScrolledRef}
|
userScrolledRef={inspectUserScrolledRef}
|
||||||
/>,
|
/>,
|
||||||
<ScoutScreen key="scout" isActive={activeStep === 3} />,
|
<ScoutScreen key="scout" isActive={hasStarted && activeStep === 3} />,
|
||||||
];
|
];
|
||||||
const ActiveIcon = active.Icon;
|
const ActiveIcon = active.Icon;
|
||||||
const showStageHeader = activeStep !== 3;
|
const showStageHeader = activeStep !== 3;
|
||||||
|
|
@ -1102,6 +1103,9 @@ function HeroProductShowcase() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [activeStep, setActiveStep] = useState(0);
|
const [activeStep, setActiveStep] = useState(0);
|
||||||
const [isStagePaused, setIsStagePaused] = useState(false);
|
const [isStagePaused, setIsStagePaused] = useState(false);
|
||||||
|
const [hasStarted, setHasStarted] = useState(false);
|
||||||
|
const [canPauseOnHover, setCanPauseOnHover] = useState(false);
|
||||||
|
const showcaseRef = useRef<HTMLDivElement | null>(null);
|
||||||
const inspectUserScrolledRef = useRef(false);
|
const inspectUserScrolledRef = useRef(false);
|
||||||
|
|
||||||
const steps: ShowcaseStep[] = [
|
const steps: ShowcaseStep[] = [
|
||||||
|
|
@ -1132,14 +1136,61 @@ function HeroProductShowcase() {
|
||||||
];
|
];
|
||||||
|
|
||||||
const active = steps[activeStep];
|
const active = steps[activeStep];
|
||||||
|
const activeStepIntervalMs = SHOWCASE_STEP_INTERVALS_MS[activeStep] ?? SHOWCASE_INTERVAL_MS;
|
||||||
|
const isProgressRunning = hasStarted && !isStagePaused;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const showcase = showcaseRef.current;
|
||||||
|
if (!showcase || hasStarted) return;
|
||||||
|
|
||||||
|
if (!('IntersectionObserver' in window)) {
|
||||||
|
setHasStarted(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (!entry.isIntersecting) return;
|
||||||
|
setHasStarted(true);
|
||||||
|
observer.disconnect();
|
||||||
|
},
|
||||||
|
{ threshold: 0.2 }
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(showcase);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [hasStarted]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window.matchMedia !== 'function') return;
|
||||||
|
|
||||||
|
const mediaQuery = window.matchMedia('(hover: hover) and (pointer: fine)');
|
||||||
|
const updateCanPause = () => {
|
||||||
|
setCanPauseOnHover(mediaQuery.matches);
|
||||||
|
if (!mediaQuery.matches) setIsStagePaused(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateCanPause();
|
||||||
|
mediaQuery.addEventListener('change', updateCanPause);
|
||||||
|
return () => mediaQuery.removeEventListener('change', updateCanPause);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const pauseForHover = () => {
|
||||||
|
if (canPauseOnHover) setIsStagePaused(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resumeAfterHover = () => {
|
||||||
|
if (canPauseOnHover) setIsStagePaused(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={showcaseRef}
|
||||||
className="home-hero-showcase dark relative w-full min-w-0 max-w-[58rem] justify-self-center"
|
className="home-hero-showcase dark relative w-full min-w-0 max-w-[58rem] justify-self-center"
|
||||||
onMouseEnter={() => setIsStagePaused(true)}
|
onMouseEnter={pauseForHover}
|
||||||
onMouseLeave={() => setIsStagePaused(false)}
|
onMouseLeave={resumeAfterHover}
|
||||||
onFocus={() => setIsStagePaused(true)}
|
onFocus={pauseForHover}
|
||||||
onBlur={() => setIsStagePaused(false)}
|
onBlur={resumeAfterHover}
|
||||||
aria-label={t('home.showcaseContext')}
|
aria-label={t('home.showcaseContext')}
|
||||||
>
|
>
|
||||||
<div className="home-hero-showcase-frame flex h-[36rem] flex-col overflow-hidden rounded-lg bg-navy-950/50 shadow-2xl shadow-black/40 backdrop-blur-sm ring-1 ring-white/10 sm:h-[38rem] md:h-[40rem]">
|
<div className="home-hero-showcase-frame flex h-[36rem] flex-col overflow-hidden rounded-lg bg-navy-950/50 shadow-2xl shadow-black/40 backdrop-blur-sm ring-1 ring-white/10 sm:h-[38rem] md:h-[40rem]">
|
||||||
|
|
@ -1171,13 +1222,13 @@ function HeroProductShowcase() {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="mt-1.5 block h-1 overflow-hidden rounded-full bg-white/10 sm:mt-2">
|
<span className="mt-1.5 block h-1 overflow-hidden rounded-full bg-white/10 sm:mt-2">
|
||||||
{activeStep === index && (
|
{hasStarted && activeStep === index && (
|
||||||
<span
|
<span
|
||||||
key={activeStep}
|
key={activeStep}
|
||||||
className="showcase-progress block h-full origin-left bg-teal-400"
|
className="showcase-progress block h-full origin-left bg-teal-400"
|
||||||
style={{
|
style={{
|
||||||
animationDuration: `${SHOWCASE_INTERVAL_MS}ms`,
|
animationDuration: `${activeStepIntervalMs}ms`,
|
||||||
animationPlayState: isStagePaused ? 'paused' : 'running',
|
animationPlayState: isProgressRunning ? 'running' : 'paused',
|
||||||
}}
|
}}
|
||||||
onAnimationEnd={() =>
|
onAnimationEnd={() =>
|
||||||
setActiveStep((step) => (step + 1) % SHOWCASE_STEP_COUNT)
|
setActiveStep((step) => (step + 1) % SHOWCASE_STEP_COUNT)
|
||||||
|
|
@ -1194,6 +1245,7 @@ function HeroProductShowcase() {
|
||||||
<DashboardShowcase
|
<DashboardShowcase
|
||||||
activeStep={activeStep}
|
activeStep={activeStep}
|
||||||
active={active}
|
active={active}
|
||||||
|
hasStarted={hasStarted}
|
||||||
inspectUserScrolledRef={inspectUserScrolledRef}
|
inspectUserScrolledRef={inspectUserScrolledRef}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1215,6 +1267,8 @@ export default function HomePage({
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [statsActive, setStatsActive] = useState(false);
|
const [statsActive, setStatsActive] = useState(false);
|
||||||
|
const homeScrollerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const homeSurfaceRef = useRef<HTMLDivElement | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => setStatsActive(true), 300);
|
const timer = setTimeout(() => setStatsActive(true), 300);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
|
|
@ -1223,6 +1277,68 @@ export default function HomePage({
|
||||||
const whyRef = useFadeInRef();
|
const whyRef = useFadeInRef();
|
||||||
const ctaRef = useFadeInRef();
|
const ctaRef = useFadeInRef();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const scroller = homeScrollerRef.current;
|
||||||
|
if (!scroller) return;
|
||||||
|
|
||||||
|
let frame = 0;
|
||||||
|
const syncParallax = () => {
|
||||||
|
frame = 0;
|
||||||
|
scroller.style.setProperty('--home-scroll-y', `${scroller.scrollTop}px`);
|
||||||
|
};
|
||||||
|
const onScroll = () => {
|
||||||
|
if (frame) return;
|
||||||
|
frame = requestAnimationFrame(syncParallax);
|
||||||
|
};
|
||||||
|
|
||||||
|
syncParallax();
|
||||||
|
scroller.addEventListener('scroll', onScroll, { passive: true });
|
||||||
|
return () => {
|
||||||
|
scroller.removeEventListener('scroll', onScroll);
|
||||||
|
if (frame) cancelAnimationFrame(frame);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const surface = homeSurfaceRef.current;
|
||||||
|
if (!surface) return;
|
||||||
|
|
||||||
|
let frame = 0;
|
||||||
|
let pointerX = 0;
|
||||||
|
let pointerY = 0;
|
||||||
|
|
||||||
|
const syncPointer = () => {
|
||||||
|
frame = 0;
|
||||||
|
surface.style.setProperty('--home-pointer-x', `${pointerX}px`);
|
||||||
|
surface.style.setProperty('--home-pointer-y', `${pointerY}px`);
|
||||||
|
};
|
||||||
|
const queuePointerSync = (event: PointerEvent) => {
|
||||||
|
const rect = surface.getBoundingClientRect();
|
||||||
|
pointerX = event.clientX - rect.left;
|
||||||
|
pointerY = event.clientY - rect.top;
|
||||||
|
if (frame) return;
|
||||||
|
frame = requestAnimationFrame(syncPointer);
|
||||||
|
};
|
||||||
|
const onPointerEnter = (event: PointerEvent) => {
|
||||||
|
queuePointerSync(event);
|
||||||
|
surface.style.setProperty('--home-pointer-active', '1');
|
||||||
|
};
|
||||||
|
const onPointerLeave = () => {
|
||||||
|
surface.style.setProperty('--home-pointer-active', '0');
|
||||||
|
};
|
||||||
|
|
||||||
|
surface.style.setProperty('--home-pointer-active', '0');
|
||||||
|
surface.addEventListener('pointerenter', onPointerEnter);
|
||||||
|
surface.addEventListener('pointermove', queuePointerSync, { passive: true });
|
||||||
|
surface.addEventListener('pointerleave', onPointerLeave);
|
||||||
|
return () => {
|
||||||
|
surface.removeEventListener('pointerenter', onPointerEnter);
|
||||||
|
surface.removeEventListener('pointermove', queuePointerSync);
|
||||||
|
surface.removeEventListener('pointerleave', onPointerLeave);
|
||||||
|
if (frame) cancelAnimationFrame(frame);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Scroll depth tracking
|
// Scroll depth tracking
|
||||||
const scrolledSections = useRef(new Set<string>());
|
const scrolledSections = useRef(new Set<string>());
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -1274,11 +1390,18 @@ export default function HomePage({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden bg-warm-50 dark:bg-navy-950 relative">
|
<div
|
||||||
|
ref={homeScrollerRef}
|
||||||
|
className="home-page-scroll flex-1 overflow-y-auto overflow-x-hidden bg-warm-50 dark:bg-navy-950 relative"
|
||||||
|
>
|
||||||
<div className="relative" style={{ zIndex: 1 }}>
|
<div className="relative" style={{ zIndex: 1 }}>
|
||||||
{/* Hero */}
|
{/* Hero */}
|
||||||
<div className="relative overflow-hidden bg-gradient-to-b from-navy-950 via-navy-900 to-navy-900 dark:from-navy-950 dark:via-navy-900 dark:to-navy-950 min-h-[calc(100dvh-3rem)] flex flex-col">
|
<div className="relative overflow-hidden bg-gradient-to-b from-navy-950 via-navy-900 to-navy-900 dark:from-navy-950 dark:via-navy-900 dark:to-navy-950 min-h-[calc(100dvh-3rem)] flex flex-col">
|
||||||
<HexCanvas isDark={theme === 'dark'} animated={false} />
|
<HexCanvas
|
||||||
|
isDark={theme === 'dark'}
|
||||||
|
animated={false}
|
||||||
|
className="home-hero-hex-parallax"
|
||||||
|
/>
|
||||||
<div className="home-hero-container relative z-10 mx-auto flex w-full max-w-[104rem] flex-1 flex-col px-6 pb-8 pt-6 backdrop-blur-[2px] md:px-10 md:py-10">
|
<div className="home-hero-container relative z-10 mx-auto flex w-full max-w-[104rem] flex-1 flex-col px-6 pb-8 pt-6 backdrop-blur-[2px] md:px-10 md:py-10">
|
||||||
<div className="home-hero-layout hero-roomy-lift grid flex-1 items-center gap-x-8 gap-y-6">
|
<div className="home-hero-layout hero-roomy-lift grid flex-1 items-center gap-x-8 gap-y-6">
|
||||||
<div className="home-hero-copy min-w-0 max-w-4xl">
|
<div className="home-hero-copy min-w-0 max-w-4xl">
|
||||||
|
|
@ -1352,7 +1475,7 @@ export default function HomePage({
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="home-content-surface relative overflow-hidden">
|
<div ref={homeSurfaceRef} className="home-content-surface relative overflow-hidden">
|
||||||
<div className="relative z-10">
|
<div className="relative z-10">
|
||||||
<ProductDemoVideo />
|
<ProductDemoVideo />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import type { AiFilterErrorType } from '../../hooks/useAiFilters';
|
||||||
/** Cycle through loading messages to show progress. */
|
/** Cycle through loading messages to show progress. */
|
||||||
function useLoadingMessage(loading: boolean, messages: string[]): string {
|
function useLoadingMessage(loading: boolean, messages: string[]): string {
|
||||||
const [index, setIndex] = useState(0);
|
const [index, setIndex] = useState(0);
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout>>();
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loading) {
|
if (!loading) {
|
||||||
|
|
@ -20,7 +20,7 @@ function useLoadingMessage(loading: boolean, messages: string[]): string {
|
||||||
const t2 = setTimeout(() => setIndex(2), 3500);
|
const t2 = setTimeout(() => setIndex(2), 3500);
|
||||||
const t3 = setTimeout(() => setIndex(3), 5500);
|
const t3 = setTimeout(() => setIndex(3), 5500);
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(timerRef.current);
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
clearTimeout(t2);
|
clearTimeout(t2);
|
||||||
clearTimeout(t3);
|
clearTimeout(t3);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,12 @@ import {
|
||||||
roundedPercentages,
|
roundedPercentages,
|
||||||
} from '../../lib/format';
|
} from '../../lib/format';
|
||||||
import { groupFeaturesByCategory } from '../../lib/features';
|
import { groupFeaturesByCategory } from '../../lib/features';
|
||||||
import { PARTY_FEATURE_COLORS, STACKED_GROUPS, STACKED_ENUM_GROUPS } from '../../lib/consts';
|
import {
|
||||||
|
PARTY_FEATURE_COLORS,
|
||||||
|
STACKED_GROUPS,
|
||||||
|
STACKED_ENUM_GROUPS,
|
||||||
|
STACKED_SEGMENT_COLORS,
|
||||||
|
} from '../../lib/consts';
|
||||||
import { DualHistogram, LoadingSkeleton } from './DualHistogram';
|
import { DualHistogram, LoadingSkeleton } from './DualHistogram';
|
||||||
import EnumBarChart from './EnumBarChart';
|
import EnumBarChart from './EnumBarChart';
|
||||||
import StackedBarChart from './StackedBarChart';
|
import StackedBarChart from './StackedBarChart';
|
||||||
|
|
@ -113,71 +118,75 @@ export default function AreaPane({
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="h-full overflow-y-auto">
|
<div className="h-full overflow-y-auto">
|
||||||
<div className="p-3">
|
<div className="border-b border-warm-200 bg-white dark:border-navy-700 dark:bg-navy-950">
|
||||||
<div className="flex items-center gap-2">
|
<div className="space-y-3 p-3">
|
||||||
<div>
|
<div className="flex items-start justify-between gap-3">
|
||||||
<h2 className="text-sm font-semibold dark:text-warm-100">
|
<div className="min-w-0">
|
||||||
{isPostcode ? hexagonId : t('areaPane.areaStatistics')}
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
</h2>
|
<h2 className="truncate text-base font-semibold text-warm-900 dark:text-warm-100">
|
||||||
{isPostcode && (
|
{isPostcode ? hexagonId : t('areaPane.areaOverview')}
|
||||||
<span className="text-xs text-warm-500 dark:text-warm-400">
|
</h2>
|
||||||
{t('common.postcode')}
|
{loading && (
|
||||||
</span>
|
<span className="h-3 w-3 shrink-0 rounded-full border-2 border-teal-600 border-t-transparent dark:border-teal-400 dark:border-t-transparent animate-spin" />
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 text-xs leading-snug text-warm-500 dark:text-warm-400">
|
||||||
|
{t('areaPane.statsFor', {
|
||||||
|
type: isPostcode
|
||||||
|
? t('common.postcode').toLowerCase()
|
||||||
|
: t('common.area').toLowerCase(),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 text-right">
|
||||||
|
<div className="text-lg font-semibold tabular-nums leading-none text-navy-950 dark:text-warm-50">
|
||||||
|
{propertyCount == null ? '...' : propertyCount.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-xs font-medium text-warm-500 dark:text-warm-400">
|
||||||
|
{t('common.propertiesPlural')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{loading && stats && (
|
|
||||||
<div className="w-3 h-3 border-2 border-teal-600 dark:border-teal-400 border-t-transparent rounded-full animate-spin" />
|
<div className="flex gap-2 border-l-2 border-teal-500 bg-warm-50 px-2.5 py-2 text-xs leading-snug text-warm-700 dark:bg-navy-900 dark:text-warm-300">
|
||||||
|
<FilterIcon className="mt-0.5 h-3.5 w-3.5 shrink-0 text-teal-700 dark:text-teal-300" />
|
||||||
|
<p>
|
||||||
|
{activeFilterCount > 0
|
||||||
|
? t('areaPane.filtersAffectStats', { count: activeFilterCount })
|
||||||
|
: t('areaPane.noFiltersAffectStats')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasFilteredOutArea && (
|
||||||
|
<div className="mt-2 rounded border border-amber-200 bg-amber-50 px-2.5 py-2 text-xs leading-snug text-amber-900 dark:border-amber-800/70 dark:bg-amber-950/40 dark:text-amber-100">
|
||||||
|
<p className="font-semibold">{t('areaPane.noFilteredMatches')}</p>
|
||||||
|
<p className="mt-1">
|
||||||
|
{unfilteredCount != null && unfilteredCount > 0
|
||||||
|
? t('areaPane.unfilteredAreaCount', { count: unfilteredCount })
|
||||||
|
: unfilteredCount === 0
|
||||||
|
? t('areaPane.noUnfilteredAreaProperties')
|
||||||
|
: t('areaPane.relaxFiltersHint')}
|
||||||
|
</p>
|
||||||
|
{onClearFilters && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClearFilters}
|
||||||
|
className="mt-2 rounded bg-amber-600 px-2 py-1 text-xs font-medium text-white hover:bg-amber-700 dark:bg-amber-500 dark:text-amber-950 dark:hover:bg-amber-400"
|
||||||
|
>
|
||||||
|
{t('filters.clearAll')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{stats && stats.count > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={onViewProperties}
|
||||||
|
className="w-full text-sm py-1.5 rounded bg-teal-600 hover:bg-teal-700 text-white font-medium"
|
||||||
|
>
|
||||||
|
{t('areaPane.viewPropertiesShort')}
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{propertyCount != null && (
|
|
||||||
<p className="text-sm text-warm-600 dark:text-warm-400 mt-1">
|
|
||||||
{propertyCount.toLocaleString()} {t('common.propertiesPlural')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<p className="text-xs text-warm-500 dark:text-warm-400 mt-1">
|
|
||||||
{t('areaPane.statsFor', {
|
|
||||||
type: isPostcode
|
|
||||||
? t('common.postcode').toLowerCase()
|
|
||||||
: t('common.area').toLowerCase(),
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
<div className="mt-2 flex gap-2 rounded border border-teal-200 bg-teal-50 px-2.5 py-2 text-xs leading-snug text-teal-800 dark:border-teal-800/70 dark:bg-teal-950/40 dark:text-teal-200">
|
|
||||||
<FilterIcon className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
|
||||||
<p>
|
|
||||||
{activeFilterCount > 0
|
|
||||||
? t('areaPane.filtersAffectStats', { count: activeFilterCount })
|
|
||||||
: t('areaPane.noFiltersAffectStats')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{hasFilteredOutArea && (
|
|
||||||
<div className="mt-2 rounded border border-amber-200 bg-amber-50 px-2.5 py-2 text-xs leading-snug text-amber-900 dark:border-amber-800/70 dark:bg-amber-950/40 dark:text-amber-100">
|
|
||||||
<p className="font-semibold">{t('areaPane.noFilteredMatches')}</p>
|
|
||||||
<p className="mt-1">
|
|
||||||
{unfilteredCount != null && unfilteredCount > 0
|
|
||||||
? t('areaPane.unfilteredAreaCount', { count: unfilteredCount })
|
|
||||||
: unfilteredCount === 0
|
|
||||||
? t('areaPane.noUnfilteredAreaProperties')
|
|
||||||
: t('areaPane.relaxFiltersHint')}
|
|
||||||
</p>
|
|
||||||
{onClearFilters && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClearFilters}
|
|
||||||
className="mt-2 rounded bg-amber-600 px-2 py-1 text-xs font-medium text-white hover:bg-amber-700 dark:bg-amber-500 dark:text-amber-950 dark:hover:bg-amber-400"
|
|
||||||
>
|
|
||||||
{t('filters.clearAll')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{stats && stats.count > 0 && (
|
|
||||||
<button
|
|
||||||
onClick={onViewProperties}
|
|
||||||
className="mt-2 w-full text-sm py-1.5 rounded bg-teal-600 hover:bg-teal-700 text-white font-medium"
|
|
||||||
>
|
|
||||||
{t('areaPane.viewProperties', { count: stats.count })}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hexagonLocation && stats && (
|
{hexagonLocation && stats && (
|
||||||
|
|
@ -315,7 +324,7 @@ export default function AreaPane({
|
||||||
colorMap={
|
colorMap={
|
||||||
chart.label === 'Political vote share'
|
chart.label === 'Political vote share'
|
||||||
? PARTY_FEATURE_COLORS
|
? PARTY_FEATURE_COLORS
|
||||||
: undefined
|
: STACKED_SEGMENT_COLORS
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -369,7 +378,15 @@ export default function AreaPane({
|
||||||
p1={numericStats.histogram.p1}
|
p1={numericStats.histogram.p1}
|
||||||
p99={numericStats.histogram.p99}
|
p99={numericStats.histogram.p99}
|
||||||
globalMean={globalMean}
|
globalMean={globalMean}
|
||||||
formatLabel={(v) => formatFilterValue(v, feature.raw)}
|
meanLabel={t('areaPane.nationalAvg')}
|
||||||
|
formatLabel={(v) =>
|
||||||
|
formatFilterValue(
|
||||||
|
v,
|
||||||
|
feature.suffix === '%'
|
||||||
|
? { raw: feature.raw, suffix: feature.suffix }
|
||||||
|
: feature.raw
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<DualHistogram
|
<DualHistogram
|
||||||
|
|
@ -377,7 +394,14 @@ export default function AreaPane({
|
||||||
globalCounts={numericStats.histogram.counts}
|
globalCounts={numericStats.histogram.counts}
|
||||||
p1={numericStats.histogram.p1}
|
p1={numericStats.histogram.p1}
|
||||||
p99={numericStats.histogram.p99}
|
p99={numericStats.histogram.p99}
|
||||||
formatLabel={(v) => formatFilterValue(v, feature.raw)}
|
formatLabel={(v) =>
|
||||||
|
formatFilterValue(
|
||||||
|
v,
|
||||||
|
feature.suffix === '%'
|
||||||
|
? { raw: feature.raw, suffix: feature.suffix }
|
||||||
|
: feature.raw
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ export function DualHistogram({
|
||||||
p1,
|
p1,
|
||||||
p99,
|
p99,
|
||||||
globalMean,
|
globalMean,
|
||||||
|
meanLabel = 'National avg',
|
||||||
formatLabel,
|
formatLabel,
|
||||||
}: {
|
}: {
|
||||||
localCounts: number[];
|
localCounts: number[];
|
||||||
|
|
@ -41,6 +42,7 @@ export function DualHistogram({
|
||||||
p1: number;
|
p1: number;
|
||||||
p99: number;
|
p99: number;
|
||||||
globalMean?: number;
|
globalMean?: number;
|
||||||
|
meanLabel?: string;
|
||||||
formatLabel?: (value: number) => string;
|
formatLabel?: (value: number) => string;
|
||||||
}) {
|
}) {
|
||||||
const targetBars = 25;
|
const targetBars = 25;
|
||||||
|
|
@ -84,34 +86,51 @@ export function DualHistogram({
|
||||||
const meanFrac = globalMean != null && p99 > p1 ? (globalMean - p1) / (p99 - p1) : null;
|
const meanFrac = globalMean != null && p99 > p1 ? (globalMean - p1) / (p99 - p1) : null;
|
||||||
// Account for outlier bins: middle region spans bars 1..n-2
|
// Account for outlier bins: middle region spans bars 1..n-2
|
||||||
const meanPct = meanFrac != null ? ((1 + meanFrac * middleBins) / barCount) * 100 : null;
|
const meanPct = meanFrac != null ? ((1 + meanFrac * middleBins) / barCount) * 100 : null;
|
||||||
|
const showMeanMarker = meanPct != null && meanPct >= 0 && meanPct <= 100;
|
||||||
|
const meanLabelStyle =
|
||||||
|
showMeanMarker && meanPct < 12
|
||||||
|
? { left: 0 }
|
||||||
|
: showMeanMarker && meanPct > 88
|
||||||
|
? { right: 0 }
|
||||||
|
: { left: '50%', transform: 'translateX(-50%)' };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<div className="relative flex items-end gap-px h-10">
|
<div className={showMeanMarker ? 'relative pt-5' : 'relative'}>
|
||||||
{Array.from({ length: barCount }).map((_, index) => {
|
<div className="relative flex items-end gap-px h-10">
|
||||||
const globalHeight = (globalBars[index] / globalMax) * 100;
|
{Array.from({ length: barCount }).map((_, index) => {
|
||||||
const localHeight = (localBars[index] / localMax) * 100;
|
const globalHeight = (globalBars[index] / globalMax) * 100;
|
||||||
return (
|
const localHeight = (localBars[index] / localMax) * 100;
|
||||||
<div key={index} className="flex-1 relative min-w-[2px] h-full flex items-end">
|
return (
|
||||||
<div
|
<div key={index} className="flex-1 relative min-w-[2px] h-full flex items-end">
|
||||||
className="absolute bottom-0 left-0 right-0 bg-warm-300/40 dark:bg-warm-600/40 rounded-t-sm"
|
<div
|
||||||
style={{ height: `${globalHeight}%` }}
|
className="absolute bottom-0 left-0 right-0 bg-warm-300/40 dark:bg-warm-600/40 rounded-t-sm"
|
||||||
/>
|
style={{ height: `${globalHeight}%` }}
|
||||||
<div
|
/>
|
||||||
className="absolute bottom-0 left-0 right-0 bg-teal-500 dark:bg-teal-400 rounded-t-sm"
|
<div
|
||||||
style={{
|
className="absolute bottom-0 left-0 right-0 bg-teal-500 dark:bg-teal-400 rounded-t-sm"
|
||||||
height: `${localHeight}%`,
|
style={{
|
||||||
opacity: localBars[index] > 0 ? 1 : 0.1,
|
height: `${localHeight}%`,
|
||||||
}}
|
opacity: localBars[index] > 0 ? 1 : 0.1,
|
||||||
/>
|
}}
|
||||||
</div>
|
/>
|
||||||
);
|
</div>
|
||||||
})}
|
);
|
||||||
{meanPct != null && meanPct >= 0 && meanPct <= 100 && (
|
})}
|
||||||
|
</div>
|
||||||
|
{showMeanMarker && (
|
||||||
<div
|
<div
|
||||||
className="absolute bottom-0 top-0 w-px border-l border-dashed border-warm-400 dark:border-warm-500"
|
className="pointer-events-none absolute inset-y-0"
|
||||||
style={{ left: `${meanPct}%` }}
|
style={{ left: `${meanPct}%` }}
|
||||||
/>
|
>
|
||||||
|
<div
|
||||||
|
className="absolute top-0 max-w-[7rem] truncate rounded-sm border border-warm-300 bg-white px-1 py-0.5 text-[9px] font-medium leading-none text-warm-600 shadow-sm dark:border-warm-600 dark:bg-navy-900 dark:text-warm-300"
|
||||||
|
style={meanLabelStyle}
|
||||||
|
>
|
||||||
|
{meanLabel}
|
||||||
|
</div>
|
||||||
|
<div className="absolute bottom-0 top-5 w-px border-l border-dashed border-warm-400 dark:border-warm-500" />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{tickBars.size > 0 && (
|
{tickBars.size > 0 && (
|
||||||
|
|
|
||||||
157
frontend/src/components/map/MobileBottomSheet.test.tsx
Normal file
157
frontend/src/components/map/MobileBottomSheet.test.tsx
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import MobileBottomSheet from './MobileBottomSheet';
|
||||||
|
|
||||||
|
class FakeVisualViewport extends EventTarget {
|
||||||
|
height: number;
|
||||||
|
width = 390;
|
||||||
|
offsetTop = 0;
|
||||||
|
offsetLeft = 0;
|
||||||
|
pageTop = 0;
|
||||||
|
pageLeft = 0;
|
||||||
|
scale = 1;
|
||||||
|
onresize: ((this: VisualViewport, ev: Event) => unknown) | null = null;
|
||||||
|
onscroll: ((this: VisualViewport, ev: Event) => unknown) | null = null;
|
||||||
|
|
||||||
|
constructor(height: number) {
|
||||||
|
super();
|
||||||
|
this.height = height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalInnerHeight = window.innerHeight;
|
||||||
|
const originalVisualViewport = window.visualViewport;
|
||||||
|
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
|
||||||
|
const originalSetPointerCapture = HTMLElement.prototype.setPointerCapture;
|
||||||
|
|
||||||
|
function installViewport({
|
||||||
|
innerHeight,
|
||||||
|
visualHeight,
|
||||||
|
}: {
|
||||||
|
innerHeight: number;
|
||||||
|
visualHeight: number;
|
||||||
|
}) {
|
||||||
|
const visualViewport = new FakeVisualViewport(visualHeight);
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'innerHeight', {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: innerHeight,
|
||||||
|
});
|
||||||
|
Object.defineProperty(window, 'visualViewport', {
|
||||||
|
configurable: true,
|
||||||
|
value: visualViewport as unknown as VisualViewport,
|
||||||
|
});
|
||||||
|
|
||||||
|
return visualViewport;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSheet() {
|
||||||
|
const coveredHeights: number[] = [];
|
||||||
|
const view = render(
|
||||||
|
<MobileBottomSheet onCoveredHeightChange={(height) => coveredHeights.push(height)}>
|
||||||
|
<label>
|
||||||
|
Name
|
||||||
|
<input aria-label="Name" />
|
||||||
|
</label>
|
||||||
|
<button type="button">Apply</button>
|
||||||
|
</MobileBottomSheet>
|
||||||
|
);
|
||||||
|
const sheet = view.container.querySelector('section');
|
||||||
|
if (!(sheet instanceof HTMLElement)) throw new Error('Expected bottom sheet section');
|
||||||
|
|
||||||
|
return { ...view, coveredHeights, sheet };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MobileBottomSheet keyboard avoidance', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
HTMLElement.prototype.scrollIntoView = vi.fn();
|
||||||
|
HTMLElement.prototype.setPointerCapture = vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
Object.defineProperty(window, 'innerHeight', {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: originalInnerHeight,
|
||||||
|
});
|
||||||
|
Object.defineProperty(window, 'visualViewport', {
|
||||||
|
configurable: true,
|
||||||
|
value: originalVisualViewport,
|
||||||
|
});
|
||||||
|
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
|
||||||
|
configurable: true,
|
||||||
|
value: originalScrollIntoView,
|
||||||
|
});
|
||||||
|
Object.defineProperty(HTMLElement.prototype, 'setPointerCapture', {
|
||||||
|
configurable: true,
|
||||||
|
value: originalSetPointerCapture,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores visual viewport keyboard inset until a sheet text field is focused', () => {
|
||||||
|
const visualViewport = installViewport({ innerHeight: 800, visualHeight: 500 });
|
||||||
|
const { sheet } = renderSheet();
|
||||||
|
|
||||||
|
expect(sheet.style.bottom).toBe('0px');
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
visualViewport.dispatchEvent(new Event('resize'));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sheet.style.bottom).toBe('0px');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears keyboard offset when focus leaves even if visual viewport is stale', async () => {
|
||||||
|
installViewport({ innerHeight: 800, visualHeight: 500 });
|
||||||
|
const { sheet } = renderSheet();
|
||||||
|
const input = screen.getByLabelText('Name');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
input.focus();
|
||||||
|
});
|
||||||
|
expect(sheet.style.bottom).toBe('300px');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
input.blur();
|
||||||
|
});
|
||||||
|
expect(sheet.style.bottom).toBe('0px');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves keyboard avoidance mode when tapping non-editable sheet content', async () => {
|
||||||
|
installViewport({ innerHeight: 800, visualHeight: 500 });
|
||||||
|
const { sheet } = renderSheet();
|
||||||
|
const input = screen.getByLabelText('Name');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
input.focus();
|
||||||
|
});
|
||||||
|
expect(sheet.style.bottom).toBe('300px');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.pointerDown(screen.getByRole('button', { name: 'Apply' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(document.activeElement).not.toBe(input);
|
||||||
|
expect(sheet.style.bottom).toBe('0px');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports covered height while the drawer is being dragged', async () => {
|
||||||
|
installViewport({ innerHeight: 800, visualHeight: 800 });
|
||||||
|
const { coveredHeights, sheet } = renderSheet();
|
||||||
|
const handle = sheet.firstElementChild;
|
||||||
|
|
||||||
|
if (!(handle instanceof HTMLElement)) throw new Error('Expected bottom sheet drag handle');
|
||||||
|
|
||||||
|
expect(coveredHeights[coveredHeights.length - 1]).toBe(352);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.pointerDown(handle, { pointerId: 1, clientY: 500 });
|
||||||
|
fireEvent.pointerMove(handle, { pointerId: 1, clientY: 400 });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(coveredHeights[coveredHeights.length - 1]).toBe(452);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { SEGMENT_COLORS } from '../../lib/consts';
|
|
||||||
import { formatValue, roundedPercentages } from '../../lib/format';
|
import { formatValue, roundedPercentages } from '../../lib/format';
|
||||||
|
|
||||||
interface Segment {
|
interface Segment {
|
||||||
|
|
@ -10,8 +9,7 @@ interface Segment {
|
||||||
interface StackedBarChartProps {
|
interface StackedBarChartProps {
|
||||||
segments: Segment[];
|
segments: Segment[];
|
||||||
total: number;
|
total: number;
|
||||||
/** Optional custom colors keyed by segment name. Falls back to SEGMENT_COLORS. */
|
colorMap: Record<string, string>;
|
||||||
colorMap?: Record<string, string>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Strip common suffixes/prefixes to produce short legend labels */
|
/** Strip common suffixes/prefixes to produce short legend labels */
|
||||||
|
|
@ -44,6 +42,14 @@ export default function StackedBarChart({ segments, total, colorMap }: StackedBa
|
||||||
return <div className="text-xs text-warm-400 dark:text-warm-500 italic">No data</div>;
|
return <div className="text-xs text-warm-400 dark:text-warm-500 italic">No data</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const colorFor = (segmentName: string): string => {
|
||||||
|
const color = colorMap[segmentName];
|
||||||
|
if (!color) {
|
||||||
|
throw new Error(`Missing stacked bar color for '${segmentName}'`);
|
||||||
|
}
|
||||||
|
return color;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{/* Stacked bar */}
|
{/* Stacked bar */}
|
||||||
|
|
@ -57,8 +63,7 @@ export default function StackedBarChart({ segments, total, colorMap }: StackedBa
|
||||||
className="h-full"
|
className="h-full"
|
||||||
style={{
|
style={{
|
||||||
width: `${pct}%`,
|
width: `${pct}%`,
|
||||||
backgroundColor:
|
backgroundColor: colorFor(segment.name),
|
||||||
colorMap?.[segment.name] ?? SEGMENT_COLORS[i % SEGMENT_COLORS.length],
|
|
||||||
}}
|
}}
|
||||||
title={`${shortenLabel(segment.name)}: ${formatValue(segment.value)} (${roundedPcts[i].toFixed(1)}%)`}
|
title={`${shortenLabel(segment.name)}: ${formatValue(segment.value)} (${roundedPcts[i].toFixed(1)}%)`}
|
||||||
/>
|
/>
|
||||||
|
|
@ -68,13 +73,12 @@ export default function StackedBarChart({ segments, total, colorMap }: StackedBa
|
||||||
|
|
||||||
{/* Legend */}
|
{/* Legend */}
|
||||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5">
|
<div className="flex flex-wrap gap-x-3 gap-y-0.5">
|
||||||
{sortedSegments.map((segment, i) => (
|
{sortedSegments.map((segment) => (
|
||||||
<div key={segment.name} className="flex items-center gap-1">
|
<div key={segment.name} className="flex items-center gap-1">
|
||||||
<span
|
<span
|
||||||
className="w-2 h-2 rounded-sm shrink-0"
|
className="w-2 h-2 rounded-sm shrink-0"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor:
|
backgroundColor: colorFor(segment.name),
|
||||||
colorMap?.[segment.name] ?? SEGMENT_COLORS[i % SEGMENT_COLORS.length],
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="text-[10px] text-warm-600 dark:text-warm-400">
|
<span className="text-[10px] text-warm-600 dark:text-warm-400">
|
||||||
|
|
|
||||||
|
|
@ -86,14 +86,14 @@ export function TravelTimeCard({
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="flex items-center gap-0.5">
|
||||||
<IconButton onClick={() => setShowInfo(true)} title={t('filters.featureInfo')}>
|
<IconButton onClick={() => setShowInfo(true)} title={t('filters.aboutData')}>
|
||||||
<InfoIcon className="w-3.5 h-3.5" />
|
<InfoIcon className="w-3.5 h-3.5" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
{slug && (
|
{slug && (
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={onTogglePin}
|
onClick={onTogglePin}
|
||||||
active={isPinned || isActive}
|
active={isPinned || isActive}
|
||||||
title={isPinned ? t('travel.stopPreviewing') : t('travel.previewOnMap')}
|
title={isPinned ? t('filters.clearColourMap') : t('filters.colourMap')}
|
||||||
>
|
>
|
||||||
<EyeIcon className="w-3.5 h-3.5" filled={isPinned || isActive} />
|
<EyeIcon className="w-3.5 h-3.5" filled={isPinned || isActive} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
|
||||||
|
|
@ -65,26 +65,27 @@ export default function PricingPage({
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const isLicensed = user?.subscription === 'licensed' || user?.isAdmin;
|
const isLicensed = user?.subscription === 'licensed' || user?.isAdmin;
|
||||||
const currentPrice = pricing?.current_price_pence ?? 10000;
|
const isFree = pricing?.current_price_pence === 0;
|
||||||
const isFree = currentPrice === 0;
|
const currentTier = pricing
|
||||||
|
? (() => {
|
||||||
// Find current tier index and remaining spots
|
let index = pricing.tiers.length - 1;
|
||||||
let currentTierIndex = (pricing?.tiers.length ?? 1) - 1;
|
let spotsRemaining = 0;
|
||||||
let spotsRemaining = 0;
|
for (let i = 0; i < pricing.tiers.length; i++) {
|
||||||
if (pricing) {
|
const tier = pricing.tiers[i];
|
||||||
for (let i = 0; i < pricing.tiers.length; i++) {
|
if (tier.up_to === null || pricing.licensed_count < tier.up_to) {
|
||||||
const tier = pricing.tiers[i];
|
index = i;
|
||||||
if (tier.up_to === null || pricing.licensed_count < tier.up_to) {
|
spotsRemaining = tier.up_to === null ? 0 : tier.up_to - pricing.licensed_count;
|
||||||
currentTierIndex = i;
|
break;
|
||||||
spotsRemaining = tier.up_to === null ? 0 : tier.up_to - pricing.licensed_count;
|
}
|
||||||
break;
|
}
|
||||||
}
|
return { index, spotsRemaining };
|
||||||
}
|
})()
|
||||||
}
|
: null;
|
||||||
|
const currentTierIndex = currentTier?.index;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pricing || !scrollRef.current || !activeCardRef.current) return;
|
if (!pricing || !scrollRef.current || !activeCardRef.current) return;
|
||||||
if (currentTierIndex === 0) return;
|
if (currentTierIndex == null || currentTierIndex === 0) return;
|
||||||
const container = scrollRef.current;
|
const container = scrollRef.current;
|
||||||
const card = activeCardRef.current;
|
const card = activeCardRef.current;
|
||||||
const containerRect = container.getBoundingClientRect();
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
|
@ -98,34 +99,42 @@ export default function PricingPage({
|
||||||
setScrolledLeft(container.scrollLeft > 0);
|
setScrolledLeft(container.scrollLeft > 0);
|
||||||
}, [pricing, currentTierIndex]);
|
}, [pricing, currentTierIndex]);
|
||||||
|
|
||||||
const ctaButton = isLicensed ? (
|
if (pricing && pricing.tiers.length === 0) {
|
||||||
<button
|
throw new Error('Pricing data did not include any tiers');
|
||||||
onClick={onOpenDashboard}
|
}
|
||||||
className="w-full mt-auto px-5 py-3 bg-teal-600 text-white rounded-lg font-semibold hover:bg-teal-700 transition-colors"
|
|
||||||
>
|
const ctaButton = pricing ? (
|
||||||
{t('pricingPage.openDashboard')}
|
isLicensed ? (
|
||||||
</button>
|
<button
|
||||||
) : user ? (
|
onClick={onOpenDashboard}
|
||||||
<button
|
className="w-full mt-auto px-5 py-3 bg-teal-600 text-white rounded-lg font-semibold hover:bg-teal-700 transition-colors"
|
||||||
onClick={() => license.startCheckout()}
|
>
|
||||||
disabled={license.checkingOut}
|
{t('pricingPage.openDashboard')}
|
||||||
className="w-full mt-auto px-5 py-3 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors shadow-lg shadow-coral-500/25 disabled:opacity-50 disabled:cursor-wait flex items-center justify-center gap-2"
|
</button>
|
||||||
>
|
) : user ? (
|
||||||
{license.checkingOut && <SpinnerIcon className="w-5 h-5 animate-spin" />}
|
<button
|
||||||
{license.checkingOut
|
onClick={() => license.startCheckout()}
|
||||||
? t('upgrade.redirecting')
|
disabled={license.checkingOut}
|
||||||
: isFree
|
className="w-full mt-auto px-5 py-3 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors shadow-lg shadow-coral-500/25 disabled:opacity-50 disabled:cursor-wait flex items-center justify-center gap-2"
|
||||||
? t('upgrade.claimFreeAccess')
|
>
|
||||||
: t('pricingPage.getStartedPrice', { price: formatPricePence(currentPrice) })}
|
{license.checkingOut && <SpinnerIcon className="w-5 h-5 animate-spin" />}
|
||||||
</button>
|
{license.checkingOut
|
||||||
) : (
|
? t('upgrade.redirecting')
|
||||||
<button
|
: isFree
|
||||||
onClick={onRegisterClick}
|
? t('upgrade.claimFreeAccess')
|
||||||
className="w-full mt-auto px-5 py-3 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors shadow-lg shadow-coral-500/25"
|
: t('pricingPage.getStartedPrice', {
|
||||||
>
|
price: formatPricePence(pricing.current_price_pence),
|
||||||
{isFree ? t('upgrade.claimFreeAccess') : t('pricingPage.getStarted')}
|
})}
|
||||||
</button>
|
</button>
|
||||||
);
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={onRegisterClick}
|
||||||
|
className="w-full mt-auto px-5 py-3 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors shadow-lg shadow-coral-500/25"
|
||||||
|
>
|
||||||
|
{isFree ? t('upgrade.claimFreeAccess') : t('pricingPage.getStarted')}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-y-auto bg-navy-950 relative">
|
<div className="flex-1 overflow-y-auto bg-navy-950 relative">
|
||||||
|
|
@ -174,8 +183,9 @@ export default function PricingPage({
|
||||||
>
|
>
|
||||||
<div className="flex w-max gap-6 mx-auto">
|
<div className="flex w-max gap-6 mx-auto">
|
||||||
{pricing.tiers.map((tier, i) => {
|
{pricing.tiers.map((tier, i) => {
|
||||||
const isCurrent = i === currentTierIndex;
|
const isCurrent = i === currentTier?.index;
|
||||||
const isFilled = tier.up_to !== null && pricing.licensed_count >= tier.up_to;
|
const isFilled = tier.up_to !== null && pricing.licensed_count >= tier.up_to;
|
||||||
|
const spotsRemaining = isCurrent ? currentTier!.spotsRemaining : 0;
|
||||||
const filledInTier = isCurrent
|
const filledInTier = isCurrent
|
||||||
? pricing.licensed_count - (i > 0 ? (pricing.tiers[i - 1].up_to ?? 0) : 0)
|
? pricing.licensed_count - (i > 0 ? (pricing.tiers[i - 1].up_to ?? 0) : 0)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
@ -245,11 +255,15 @@ export default function PricingPage({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isCurrent && spotsRemaining > 0 && (
|
{spotsRemaining > 0 && (
|
||||||
<p className="text-teal-300 text-sm mt-2 font-medium">
|
<p className="text-teal-300 text-sm mt-2 font-medium">
|
||||||
{spotsRemaining === 1
|
{spotsRemaining === 1
|
||||||
? t('pricingPage.spotsRemaining', { count: spotsRemaining })
|
? t('pricingPage.spotsRemaining', {
|
||||||
: t('pricingPage.spotsRemainingPlural', { count: spotsRemaining })}
|
count: spotsRemaining,
|
||||||
|
})
|
||||||
|
: t('pricingPage.spotsRemainingPlural', {
|
||||||
|
count: spotsRemaining,
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{isFilled && (
|
{isFilled && (
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,9 @@ function layerById(layers: readonly unknown[], id: string) {
|
||||||
return layer as { props: Record<string, unknown> };
|
return layer as { props: Record<string, unknown> };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PoiIconDef = { url: string; width: number; height: number };
|
||||||
|
type PoiColor = [number, number, number, number];
|
||||||
|
|
||||||
describe('usePoiLayers', () => {
|
describe('usePoiLayers', () => {
|
||||||
it('returns the expected layer stack', () => {
|
it('returns the expected layer stack', () => {
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
|
|
@ -71,9 +74,15 @@ describe('usePoiLayers', () => {
|
||||||
usePoiLayers({ pois: [waitrose], zoom: 15, isDark: false })
|
usePoiLayers({ pois: [waitrose], zoom: 15, isDark: false })
|
||||||
);
|
);
|
||||||
const iconLayer = layerById(result.current.poiLayers, 'poi-icons');
|
const iconLayer = layerById(result.current.poiLayers, 'poi-icons');
|
||||||
const getIcon = iconLayer.props.getIcon as (poi: POI) => { url: string };
|
const getIcon = iconLayer.props.getIcon as (poi: POI) => PoiIconDef;
|
||||||
|
const getSize = iconLayer.props.getSize as (poi: POI) => number;
|
||||||
|
|
||||||
expect(getIcon(waitrose).url).toBe('/assets/poi-icons/brands/waitrose_24px.svg');
|
expect(getIcon(waitrose)).toMatchObject({
|
||||||
|
url: '/assets/poi-icons/logos/waitrose.svg',
|
||||||
|
width: 96,
|
||||||
|
height: 48,
|
||||||
|
});
|
||||||
|
expect(getSize(waitrose)).toBe(24);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prefers POI fascia icon categories for map marker icons', () => {
|
it('prefers POI fascia icon categories for map marker icons', () => {
|
||||||
|
|
@ -81,11 +90,53 @@ describe('usePoiLayers', () => {
|
||||||
usePoiLayers({ pois: [foodWarehouse], zoom: 15, isDark: false })
|
usePoiLayers({ pois: [foodWarehouse], zoom: 15, isDark: false })
|
||||||
);
|
);
|
||||||
const iconLayer = layerById(result.current.poiLayers, 'poi-icons');
|
const iconLayer = layerById(result.current.poiLayers, 'poi-icons');
|
||||||
const getIcon = iconLayer.props.getIcon as (poi: POI) => { url: string };
|
const getIcon = iconLayer.props.getIcon as (poi: POI) => PoiIconDef;
|
||||||
|
const getSize = iconLayer.props.getSize as (poi: POI) => number;
|
||||||
|
|
||||||
expect(getIcon(foodWarehouse).url).toBe(
|
expect(getIcon(foodWarehouse)).toMatchObject({
|
||||||
'/assets/poi-icons/brands/iceland_food_warehouse_24px.svg'
|
url: '/assets/poi-icons/logos/the_food_warehouse.png',
|
||||||
|
width: 96,
|
||||||
|
height: 48,
|
||||||
|
});
|
||||||
|
expect(getSize(foodWarehouse)).toBe(24);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps generic emoji POIs at the compact marker size', () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
usePoiLayers({ pois: [supermarket], zoom: 15, isDark: false })
|
||||||
);
|
);
|
||||||
|
const iconLayer = layerById(result.current.poiLayers, 'poi-icons');
|
||||||
|
const getIcon = iconLayer.props.getIcon as (poi: POI) => PoiIconDef;
|
||||||
|
const getSize = iconLayer.props.getSize as (poi: POI) => number;
|
||||||
|
|
||||||
|
expect(getIcon(supermarket)).toMatchObject({
|
||||||
|
url: '/assets/twemoji/1f6d2.png',
|
||||||
|
width: 72,
|
||||||
|
height: 72,
|
||||||
|
});
|
||||||
|
expect(getSize(supermarket)).toBe(18);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the circular marker badge behind bundled logo icons', () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
usePoiLayers({ pois: [waitrose, supermarket], zoom: 15, isDark: false })
|
||||||
|
);
|
||||||
|
const shadowLayer = layerById(result.current.poiLayers, 'poi-shadow');
|
||||||
|
const backgroundLayer = layerById(result.current.poiLayers, 'poi-background');
|
||||||
|
const getShadowRadius = shadowLayer.props.getRadius as (poi: POI) => number;
|
||||||
|
const getBackgroundRadius = backgroundLayer.props.getRadius as (poi: POI) => number;
|
||||||
|
const getFillColor = backgroundLayer.props.getFillColor as (poi: POI) => PoiColor;
|
||||||
|
const getLineColor = backgroundLayer.props.getLineColor as (poi: POI) => PoiColor;
|
||||||
|
|
||||||
|
expect(getShadowRadius(waitrose)).toBe(0);
|
||||||
|
expect(getBackgroundRadius(waitrose)).toBe(24);
|
||||||
|
expect(getFillColor(waitrose)).toEqual([0, 0, 0, 0]);
|
||||||
|
expect(getLineColor(waitrose)).toEqual([0, 0, 0, 0]);
|
||||||
|
|
||||||
|
expect(getShadowRadius(supermarket)).toBe(16);
|
||||||
|
expect(getBackgroundRadius(supermarket)).toBe(14);
|
||||||
|
expect(getFillColor(supermarket)).toEqual([255, 255, 255, 255]);
|
||||||
|
expect(getLineColor(supermarket)).toEqual([34, 197, 94, 255]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hides minor POI categories until the configured zoom threshold', () => {
|
it('hides minor POI categories until the configured zoom threshold', () => {
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,11 @@ const de: Translations = {
|
||||||
exportLabel: 'Exportieren',
|
exportLabel: 'Exportieren',
|
||||||
exporting: 'Wird exportiert...',
|
exporting: 'Wird exportiert...',
|
||||||
exportToExcel: 'Als Excel exportieren',
|
exportToExcel: 'Als Excel exportieren',
|
||||||
|
exportReady: 'Export bereit. Der Download sollte starten.',
|
||||||
|
exportFailed: 'Export fehlgeschlagen.',
|
||||||
|
exportTimedOut: 'Export timed out. Bitte erneut versuchen.',
|
||||||
|
exportUnavailable: 'Die Karte lädt noch. Bitte gleich erneut versuchen.',
|
||||||
|
exportEmpty: 'Der Export wurde abgeschlossen, aber die Datei ist leer.',
|
||||||
openMenu: 'Menü öffnen',
|
openMenu: 'Menü öffnen',
|
||||||
closeMenu: 'Menü schließen',
|
closeMenu: 'Menü schließen',
|
||||||
},
|
},
|
||||||
|
|
@ -137,13 +142,22 @@ const de: Translations = {
|
||||||
'Finde passende Postleitzahlen mit Kriminalität, Schulen, Lärm, Breitband, Preisen und über 50 weiteren Filtern in ganz England.',
|
'Finde passende Postleitzahlen mit Kriminalität, Schulen, Lärm, Breitband, Preisen und über 50 weiteren Filtern in ganz England.',
|
||||||
oneTimeLifetime: 'Einmalzahlung, lebenslanger Zugang.',
|
oneTimeLifetime: 'Einmalzahlung, lebenslanger Zugang.',
|
||||||
upgradeToFullMap: 'Zur Vollversion upgraden',
|
upgradeToFullMap: 'Zur Vollversion upgraden',
|
||||||
chooseFilters: 'Wähle die Filter, die dir wichtig sind. Die Karte aktualisiert sich sofort.',
|
chooseFilters:
|
||||||
|
'Klicke auf Hinzufügen, um zu filtern. Die kleinen Buttons zeigen Daten oder färben die Karte.',
|
||||||
searchFeatures: 'Filter durchsuchen...',
|
searchFeatures: 'Filter durchsuchen...',
|
||||||
noMatchingFeatures: 'Keine passenden Filter',
|
noMatchingFeatures: 'Keine passenden Filter',
|
||||||
tryDifferentSearch: 'Versuche einen anderen Suchbegriff',
|
tryDifferentSearch: 'Versuche einen anderen Suchbegriff',
|
||||||
allFeaturesActive: 'Alle Filter sind aktiv',
|
allFeaturesActive: 'Alle Filter sind aktiv',
|
||||||
removeFilterHint: 'Entferne einen Filter, um verfügbare Merkmale zu sehen',
|
removeFilterHint: 'Entferne einen Filter, um verfügbare Merkmale zu sehen',
|
||||||
featureInfo: 'Filterinfo',
|
featureInfo: 'Über diese Daten',
|
||||||
|
aboutData: 'Über diese Daten',
|
||||||
|
aboutDataShort: 'Info',
|
||||||
|
colourMap: 'Karte einfärben',
|
||||||
|
colourMapShort: 'Karte färben',
|
||||||
|
clearColourMap: 'Kartenfarbe löschen',
|
||||||
|
addFilterAction: 'Hinzufügen',
|
||||||
|
addFilterLabel: 'Filter hinzufügen',
|
||||||
|
removeFilter: 'Filter entfernen',
|
||||||
replayTutorial: 'Interaktives Tutorial erneut abspielen',
|
replayTutorial: 'Interaktives Tutorial erneut abspielen',
|
||||||
clearAll: 'Alle löschen',
|
clearAll: 'Alle löschen',
|
||||||
clearAllTitle: 'Alle Filter löschen?',
|
clearAllTitle: 'Alle Filter löschen?',
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,11 @@ const en = {
|
||||||
exportLabel: 'Export',
|
exportLabel: 'Export',
|
||||||
exporting: 'Exporting...',
|
exporting: 'Exporting...',
|
||||||
exportToExcel: 'Export to Excel',
|
exportToExcel: 'Export to Excel',
|
||||||
|
exportReady: 'Export ready. Your download should start.',
|
||||||
|
exportFailed: 'Export failed.',
|
||||||
|
exportTimedOut: 'Export timed out. Try again.',
|
||||||
|
exportUnavailable: 'The map is still loading. Try again in a moment.',
|
||||||
|
exportEmpty: 'Export finished but returned an empty file.',
|
||||||
openMenu: 'Open menu',
|
openMenu: 'Open menu',
|
||||||
closeMenu: 'Close menu',
|
closeMenu: 'Close menu',
|
||||||
},
|
},
|
||||||
|
|
@ -133,13 +138,21 @@ const en = {
|
||||||
'Find matching postcodes using crime, schools, noise, broadband, prices, and 50+ more filters across England.',
|
'Find matching postcodes using crime, schools, noise, broadband, prices, and 50+ more filters across England.',
|
||||||
oneTimeLifetime: 'One-time payment, lifetime access.',
|
oneTimeLifetime: 'One-time payment, lifetime access.',
|
||||||
upgradeToFullMap: 'Upgrade to full map',
|
upgradeToFullMap: 'Upgrade to full map',
|
||||||
chooseFilters: 'Choose the filters that matter to you. The map updates as you go.',
|
chooseFilters: 'Click Add to filter. The small buttons show data details or colour the map.',
|
||||||
searchFeatures: 'Search features...',
|
searchFeatures: 'Search features...',
|
||||||
noMatchingFeatures: 'No matching features',
|
noMatchingFeatures: 'No matching features',
|
||||||
tryDifferentSearch: 'Try a different search term',
|
tryDifferentSearch: 'Try a different search term',
|
||||||
allFeaturesActive: 'All features are active',
|
allFeaturesActive: 'All features are active',
|
||||||
removeFilterHint: 'Remove a filter to see available features',
|
removeFilterHint: 'Remove a filter to see available features',
|
||||||
featureInfo: 'Feature info',
|
featureInfo: 'About this data',
|
||||||
|
aboutData: 'About this data',
|
||||||
|
aboutDataShort: 'About',
|
||||||
|
colourMap: 'Colour map',
|
||||||
|
colourMapShort: 'Colour map',
|
||||||
|
clearColourMap: 'Clear map colour',
|
||||||
|
addFilterAction: 'Add',
|
||||||
|
addFilterLabel: 'Add filter',
|
||||||
|
removeFilter: 'Remove filter',
|
||||||
replayTutorial: 'Replay interactive tutorial',
|
replayTutorial: 'Replay interactive tutorial',
|
||||||
clearAll: 'Clear all',
|
clearAll: 'Clear all',
|
||||||
clearAllTitle: 'Clear all filters?',
|
clearAllTitle: 'Clear all filters?',
|
||||||
|
|
@ -644,13 +657,13 @@ const en = {
|
||||||
// FAQ items — Tips and Tricks
|
// FAQ items — Tips and Tricks
|
||||||
faqTips1Q: 'How do I preview a filter on the map?',
|
faqTips1Q: 'How do I preview a filter on the map?',
|
||||||
faqTips1A:
|
faqTips1A:
|
||||||
'Click the eye icon beside a filter or feature to colour the map by that item. Your active filters stay in place, so the eye preview is a quick way to compare one thing, such as price, commute time, schools, crime, or noise, without changing your shortlist.',
|
'Click Colour beside a filter or feature to colour the map by that item. Your active filters stay in place, so this is a quick way to compare one thing, such as price, commute time, schools, crime, or noise, without changing your shortlist.',
|
||||||
faqTips2Q: 'How do I learn what a filter means?',
|
faqTips2Q: 'How do I learn what a filter means?',
|
||||||
faqTips2A:
|
faqTips2A:
|
||||||
'Click the i info button next to a filter or feature to open a short explanation of what it means and how to read it. Some areas of the map, such as travel-time cards, also have their own info button.',
|
'Click About next to a filter or feature to open a short explanation of what it means and how to read it. Some areas of the map, such as travel-time cards, also have their own data explanation.',
|
||||||
faqTips3Q: 'How do I refresh the map colours?',
|
faqTips3Q: 'How do I refresh the map colours?',
|
||||||
faqTips3A:
|
faqTips3A:
|
||||||
'When an eye preview is colouring the map, use Reset colour scale in the map legend to refresh the colours for the results you’re looking at now. This is useful after moving the map, zooming, or changing filters.',
|
'When a feature is colouring the map, use Reset colour scale in the map legend to refresh the colours for the results you’re looking at now. This is useful after moving the map, zooming, or changing filters.',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Account Page ───────────────────────────────────
|
// ── Account Page ───────────────────────────────────
|
||||||
|
|
@ -760,7 +773,7 @@ const en = {
|
||||||
tutorial: {
|
tutorial: {
|
||||||
step1Title: 'Tell the map what matters',
|
step1Title: 'Tell the map what matters',
|
||||||
step1Content:
|
step1Content:
|
||||||
'Set your budget, commute limit, school quality, crime threshold, noise tolerance, broadband needs, or whatever matters to you. Only matching areas stay lit. Use the eye icon to colour by any feature.',
|
'Set your budget, commute limit, school quality, crime threshold, noise tolerance, broadband needs, or whatever matters to you. Only matching areas stay lit. Use Colour to shade the map by any feature.',
|
||||||
step2Title: 'Or just describe it',
|
step2Title: 'Or just describe it',
|
||||||
step2Content:
|
step2Content:
|
||||||
'Type what you want in plain English, like "quiet area near good schools under £400k", and we’ll set up the filters for you.',
|
'Type what you want in plain English, like "quiet area near good schools under £400k", and we’ll set up the filters for you.',
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,11 @@ const fr: Translations = {
|
||||||
exportLabel: 'Exporter',
|
exportLabel: 'Exporter',
|
||||||
exporting: 'Exportation...',
|
exporting: 'Exportation...',
|
||||||
exportToExcel: 'Exporter vers Excel',
|
exportToExcel: 'Exporter vers Excel',
|
||||||
|
exportReady: 'Export prêt. Le téléchargement devrait commencer.',
|
||||||
|
exportFailed: 'Échec de l’export.',
|
||||||
|
exportTimedOut: 'L’export a expiré. Réessayez.',
|
||||||
|
exportUnavailable: 'La carte charge encore. Réessayez dans un instant.',
|
||||||
|
exportEmpty: 'L’export est terminé, mais le fichier est vide.',
|
||||||
openMenu: 'Ouvrir le menu',
|
openMenu: 'Ouvrir le menu',
|
||||||
closeMenu: 'Fermer le menu',
|
closeMenu: 'Fermer le menu',
|
||||||
},
|
},
|
||||||
|
|
@ -139,13 +144,21 @@ const fr: Translations = {
|
||||||
oneTimeLifetime: 'Paiement unique, accès à vie.',
|
oneTimeLifetime: 'Paiement unique, accès à vie.',
|
||||||
upgradeToFullMap: 'Passer à la carte complète',
|
upgradeToFullMap: 'Passer à la carte complète',
|
||||||
chooseFilters:
|
chooseFilters:
|
||||||
'Choisissez les filtres qui comptent pour vous. La carte se met à jour en temps réel.',
|
'Cliquez sur Ajouter pour filtrer. Les petits boutons affichent les données ou colorent la carte.',
|
||||||
searchFeatures: 'Rechercher des critères...',
|
searchFeatures: 'Rechercher des critères...',
|
||||||
noMatchingFeatures: 'Aucun critère correspondant',
|
noMatchingFeatures: 'Aucun critère correspondant',
|
||||||
tryDifferentSearch: 'Essayez un autre terme de recherche',
|
tryDifferentSearch: 'Essayez un autre terme de recherche',
|
||||||
allFeaturesActive: 'Tous les critères sont actifs',
|
allFeaturesActive: 'Tous les critères sont actifs',
|
||||||
removeFilterHint: 'Supprimez un filtre pour voir les critères disponibles',
|
removeFilterHint: 'Supprimez un filtre pour voir les critères disponibles',
|
||||||
featureInfo: 'Informations sur le critère',
|
featureInfo: 'À propos de ces données',
|
||||||
|
aboutData: 'À propos de ces données',
|
||||||
|
aboutDataShort: 'À propos',
|
||||||
|
colourMap: 'Colorer la carte',
|
||||||
|
colourMapShort: 'Colorer carte',
|
||||||
|
clearColourMap: 'Effacer la couleur de la carte',
|
||||||
|
addFilterAction: 'Ajouter',
|
||||||
|
addFilterLabel: 'Ajouter un filtre',
|
||||||
|
removeFilter: 'Supprimer le filtre',
|
||||||
replayTutorial: 'Rejouer le tutoriel interactif',
|
replayTutorial: 'Rejouer le tutoriel interactif',
|
||||||
clearAll: 'Tout effacer',
|
clearAll: 'Tout effacer',
|
||||||
clearAllTitle: 'Effacer tous les filtres ?',
|
clearAllTitle: 'Effacer tous les filtres ?',
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,11 @@ const hi: Translations = {
|
||||||
exportLabel: 'निर्यात',
|
exportLabel: 'निर्यात',
|
||||||
exporting: 'निर्यात हो रहा है...',
|
exporting: 'निर्यात हो रहा है...',
|
||||||
exportToExcel: 'Excel में निर्यात करें',
|
exportToExcel: 'Excel में निर्यात करें',
|
||||||
|
exportReady: 'निर्यात तैयार है. डाउनलोड शुरू होना चाहिए.',
|
||||||
|
exportFailed: 'निर्यात विफल रहा.',
|
||||||
|
exportTimedOut: 'निर्यात का समय समाप्त हो गया. फिर कोशिश करें.',
|
||||||
|
exportUnavailable: 'मैप अभी लोड हो रहा है. थोड़ी देर में फिर कोशिश करें.',
|
||||||
|
exportEmpty: 'निर्यात पूरा हुआ, लेकिन फ़ाइल खाली है.',
|
||||||
openMenu: 'मेनू खोलें',
|
openMenu: 'मेनू खोलें',
|
||||||
closeMenu: 'मेनू बंद करें',
|
closeMenu: 'मेनू बंद करें',
|
||||||
},
|
},
|
||||||
|
|
@ -127,13 +132,22 @@ const hi: Translations = {
|
||||||
'इंग्लैंड भर में अपराध, स्कूल, शोर, ब्रॉडबैंड, कीमतें और 50+ अन्य फिल्टर से मेल खाने वाले पोस्टकोड खोजें.',
|
'इंग्लैंड भर में अपराध, स्कूल, शोर, ब्रॉडबैंड, कीमतें और 50+ अन्य फिल्टर से मेल खाने वाले पोस्टकोड खोजें.',
|
||||||
oneTimeLifetime: 'एक बार भुगतान, लाइफटाइम एक्सेस.',
|
oneTimeLifetime: 'एक बार भुगतान, लाइफटाइम एक्सेस.',
|
||||||
upgradeToFullMap: 'पूरा मानचित्र अपग्रेड करें',
|
upgradeToFullMap: 'पूरा मानचित्र अपग्रेड करें',
|
||||||
chooseFilters: 'जो फिल्टर आपके लिए मायने रखते हैं उन्हें चुनें. मानचित्र तुरंत अपडेट होता है.',
|
chooseFilters:
|
||||||
|
'Filter लगाने के लिए Add पर click करें. छोटे buttons data details दिखाते हैं या map colour करते हैं.',
|
||||||
searchFeatures: 'फीचर खोजें...',
|
searchFeatures: 'फीचर खोजें...',
|
||||||
noMatchingFeatures: 'कोई मेल खाता फीचर नहीं',
|
noMatchingFeatures: 'कोई मेल खाता फीचर नहीं',
|
||||||
tryDifferentSearch: 'कोई दूसरा खोज शब्द आजमाएं',
|
tryDifferentSearch: 'कोई दूसरा खोज शब्द आजमाएं',
|
||||||
allFeaturesActive: 'सभी फीचर सक्रिय हैं',
|
allFeaturesActive: 'सभी फीचर सक्रिय हैं',
|
||||||
removeFilterHint: 'उपलब्ध फीचर देखने के लिए कोई फिल्टर हटाएं',
|
removeFilterHint: 'उपलब्ध फीचर देखने के लिए कोई फिल्टर हटाएं',
|
||||||
featureInfo: 'फीचर जानकारी',
|
featureInfo: 'इस डेटा के बारे में',
|
||||||
|
aboutData: 'इस डेटा के बारे में',
|
||||||
|
aboutDataShort: 'जानकारी',
|
||||||
|
colourMap: 'मानचित्र रंगें',
|
||||||
|
colourMapShort: 'मानचित्र रंगें',
|
||||||
|
clearColourMap: 'मानचित्र का रंग हटाएं',
|
||||||
|
addFilterAction: 'जोड़ें',
|
||||||
|
addFilterLabel: 'फिल्टर जोड़ें',
|
||||||
|
removeFilter: 'फिल्टर हटाएं',
|
||||||
replayTutorial: 'इंटरैक्टिव ट्यूटोरियल फिर चलाएं',
|
replayTutorial: 'इंटरैक्टिव ट्यूटोरियल फिर चलाएं',
|
||||||
clearAll: 'सभी साफ करें',
|
clearAll: 'सभी साफ करें',
|
||||||
clearAllTitle: 'सभी फिल्टर साफ करें?',
|
clearAllTitle: 'सभी फिल्टर साफ करें?',
|
||||||
|
|
@ -608,13 +622,13 @@ const hi: Translations = {
|
||||||
'मुफ्त उपयोगकर्ता डेमो क्षेत्र (inner London, लगभग zones 1 to 2) के अंदर सभी फीचर देख सकते हैं. England के बाकी डेटा के लिए लाइफटाइम एक्सेस चाहिए.',
|
'मुफ्त उपयोगकर्ता डेमो क्षेत्र (inner London, लगभग zones 1 to 2) के अंदर सभी फीचर देख सकते हैं. England के बाकी डेटा के लिए लाइफटाइम एक्सेस चाहिए.',
|
||||||
faqTips1Q: 'Map पर filter preview कैसे करें?',
|
faqTips1Q: 'Map पर filter preview कैसे करें?',
|
||||||
faqTips1A:
|
faqTips1A:
|
||||||
'किसी filter या feature के पास eye icon पर click करें ताकि map उसी item से colour हो जाए. आपके active filters वैसे ही रहते हैं, इसलिए आप price, commute time, schools, crime या noise जैसी एक चीज shortlist बदले बिना compare कर सकते हैं.',
|
'किसी filter या feature के पास Colour पर click करें ताकि map उसी item से colour हो जाए. आपके active filters वैसे ही रहते हैं, इसलिए आप price, commute time, schools, crime या noise जैसी एक चीज shortlist बदले बिना compare कर सकते हैं.',
|
||||||
faqTips2Q: 'किसी filter का मतलब कैसे जानूं?',
|
faqTips2Q: 'किसी filter का मतलब कैसे जानूं?',
|
||||||
faqTips2A:
|
faqTips2A:
|
||||||
'किसी filter या feature के पास i info button पर click करें ताकि छोटा explanation खुले कि उसका मतलब क्या है और उसे कैसे पढ़ें. Map के कुछ हिस्सों, जैसे travel-time cards, का अपना info button भी होता है.',
|
'किसी filter या feature के पास About पर click करें ताकि छोटा explanation खुले कि उसका मतलब क्या है और उसे कैसे पढ़ें. Map के कुछ हिस्सों, जैसे travel-time cards, की अपनी data explanation भी होती है.',
|
||||||
faqTips3Q: 'Map colours कैसे refresh करें?',
|
faqTips3Q: 'Map colours कैसे refresh करें?',
|
||||||
faqTips3A:
|
faqTips3A:
|
||||||
'जब eye preview map को colour कर रहा हो, तो map legend में Reset colour scale उपयोग करें ताकि अभी दिख रहे results के colours refresh हों. Map move, zoom या filters बदलने के बाद यह उपयोगी है.',
|
'जब कोई feature map को colour कर रहा हो, तो map legend में Reset colour scale उपयोग करें ताकि अभी दिख रहे results के colours refresh हों. Map move, zoom या filters बदलने के बाद यह उपयोगी है.',
|
||||||
},
|
},
|
||||||
|
|
||||||
accountPage: {
|
accountPage: {
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,11 @@ const hu: Translations = {
|
||||||
exportLabel: 'Exportálás',
|
exportLabel: 'Exportálás',
|
||||||
exporting: 'Exportálás...',
|
exporting: 'Exportálás...',
|
||||||
exportToExcel: 'Exportálás Excelbe',
|
exportToExcel: 'Exportálás Excelbe',
|
||||||
|
exportReady: 'Export kész. A letöltésnek el kell indulnia.',
|
||||||
|
exportFailed: 'Az exportálás sikertelen.',
|
||||||
|
exportTimedOut: 'Az exportálás időtúllépés miatt leállt. Próbáld újra.',
|
||||||
|
exportUnavailable: 'A térkép még tölt. Próbáld újra hamarosan.',
|
||||||
|
exportEmpty: 'Az exportálás befejeződött, de a fájl üres.',
|
||||||
openMenu: 'Menü megnyitása',
|
openMenu: 'Menü megnyitása',
|
||||||
closeMenu: 'Menü bezárása',
|
closeMenu: 'Menü bezárása',
|
||||||
},
|
},
|
||||||
|
|
@ -136,13 +141,22 @@ const hu: Translations = {
|
||||||
'Találj megfelelő irányítószámokat bűnözés, iskolák, zaj, szélessáv, árak és több mint 50 további szűrő alapján egész Angliában.',
|
'Találj megfelelő irányítószámokat bűnözés, iskolák, zaj, szélessáv, árak és több mint 50 további szűrő alapján egész Angliában.',
|
||||||
oneTimeLifetime: 'Egyszeri fizetés, élethosszig tartó hozzáférés.',
|
oneTimeLifetime: 'Egyszeri fizetés, élethosszig tartó hozzáférés.',
|
||||||
upgradeToFullMap: 'Frissítés a teljes térképre',
|
upgradeToFullMap: 'Frissítés a teljes térképre',
|
||||||
chooseFilters: 'Válaszd ki a számodra fontos szűrőket. A térkép menet közben frissül.',
|
chooseFilters:
|
||||||
|
'Kattints a Hozzáadásra a szűréshez. A kis gombok adatokat mutatnak vagy színezik a térképet.',
|
||||||
searchFeatures: 'Jellemzők keresése...',
|
searchFeatures: 'Jellemzők keresése...',
|
||||||
noMatchingFeatures: 'Nincs találat',
|
noMatchingFeatures: 'Nincs találat',
|
||||||
tryDifferentSearch: 'Próbálj más keresőkifejezést',
|
tryDifferentSearch: 'Próbálj más keresőkifejezést',
|
||||||
allFeaturesActive: 'Minden jellemző aktív',
|
allFeaturesActive: 'Minden jellemző aktív',
|
||||||
removeFilterHint: 'Távolíts el egy szűrőt az elérhető jellemzők megtekintéséhez',
|
removeFilterHint: 'Távolíts el egy szűrőt az elérhető jellemzők megtekintéséhez',
|
||||||
featureInfo: 'Jellemző információ',
|
featureInfo: 'Az adatról',
|
||||||
|
aboutData: 'Az adatról',
|
||||||
|
aboutDataShort: 'Adat',
|
||||||
|
colourMap: 'Térkép színezése',
|
||||||
|
colourMapShort: 'Térkép színezése',
|
||||||
|
clearColourMap: 'Térképszínezés törlése',
|
||||||
|
addFilterAction: 'Hozzáadás',
|
||||||
|
addFilterLabel: 'Szűrő hozzáadása',
|
||||||
|
removeFilter: 'Szűrő eltávolítása',
|
||||||
replayTutorial: 'Interaktív bemutató újrajátszása',
|
replayTutorial: 'Interaktív bemutató újrajátszása',
|
||||||
clearAll: 'Összes törlése',
|
clearAll: 'Összes törlése',
|
||||||
clearAllTitle: 'Összes szűrő törlése?',
|
clearAllTitle: 'Összes szűrő törlése?',
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,11 @@ const zh: Translations = {
|
||||||
exportLabel: '导出',
|
exportLabel: '导出',
|
||||||
exporting: '导出中...',
|
exporting: '导出中...',
|
||||||
exportToExcel: '导出为 Excel',
|
exportToExcel: '导出为 Excel',
|
||||||
|
exportReady: '导出已就绪。下载应会开始。',
|
||||||
|
exportFailed: '导出失败。',
|
||||||
|
exportTimedOut: '导出超时。请重试。',
|
||||||
|
exportUnavailable: '地图仍在加载。请稍后重试。',
|
||||||
|
exportEmpty: '导出已完成,但文件为空。',
|
||||||
openMenu: '打开菜单',
|
openMenu: '打开菜单',
|
||||||
closeMenu: '关闭菜单',
|
closeMenu: '关闭菜单',
|
||||||
},
|
},
|
||||||
|
|
@ -134,13 +139,21 @@ const zh: Translations = {
|
||||||
'用犯罪率、学校、噪音、宽带、价格和 50 多项其他筛选条件,在整个英格兰找到匹配的邮编。',
|
'用犯罪率、学校、噪音、宽带、价格和 50 多项其他筛选条件,在整个英格兰找到匹配的邮编。',
|
||||||
oneTimeLifetime: '一次性付款,终身访问。',
|
oneTimeLifetime: '一次性付款,终身访问。',
|
||||||
upgradeToFullMap: '升级到完整地图',
|
upgradeToFullMap: '升级到完整地图',
|
||||||
chooseFilters: '选择您关心的筛选条件,地图会随之实时更新。',
|
chooseFilters: '点击“添加”来筛选。小按钮可查看数据说明或给地图着色。',
|
||||||
searchFeatures: '搜索数据指标...',
|
searchFeatures: '搜索数据指标...',
|
||||||
noMatchingFeatures: '没有匹配的数据指标',
|
noMatchingFeatures: '没有匹配的数据指标',
|
||||||
tryDifferentSearch: '尝试不同的搜索词',
|
tryDifferentSearch: '尝试不同的搜索词',
|
||||||
allFeaturesActive: '所有数据指标已启用',
|
allFeaturesActive: '所有数据指标已启用',
|
||||||
removeFilterHint: '移除一个筛选条件以查看可用的数据指标',
|
removeFilterHint: '移除一个筛选条件以查看可用的数据指标',
|
||||||
featureInfo: '数据指标信息',
|
featureInfo: '关于此数据',
|
||||||
|
aboutData: '关于此数据',
|
||||||
|
aboutDataShort: '关于',
|
||||||
|
colourMap: '给地图着色',
|
||||||
|
colourMapShort: '地图着色',
|
||||||
|
clearColourMap: '清除地图着色',
|
||||||
|
addFilterAction: '添加',
|
||||||
|
addFilterLabel: '添加筛选条件',
|
||||||
|
removeFilter: '移除筛选条件',
|
||||||
replayTutorial: '重新播放交互教程',
|
replayTutorial: '重新播放交互教程',
|
||||||
clearAll: '全部清除',
|
clearAll: '全部清除',
|
||||||
clearAllTitle: '清除所有筛选条件?',
|
clearAllTitle: '清除所有筛选条件?',
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
@tailwind base;
|
@config "../tailwind.config.js";
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
@import "tailwindcss";
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body,
|
body,
|
||||||
|
|
@ -45,26 +45,6 @@ h3 {
|
||||||
color 0.2s ease;
|
color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
header a,
|
|
||||||
header a *,
|
|
||||||
header button:not(:disabled),
|
|
||||||
header button:not(:disabled) *,
|
|
||||||
.mobile-menu-panel a,
|
|
||||||
.mobile-menu-panel a *,
|
|
||||||
.mobile-menu-panel button:not(:disabled),
|
|
||||||
.mobile-menu-panel button:not(:disabled) *,
|
|
||||||
.home-hero-showcase button:not(:disabled),
|
|
||||||
.home-hero-showcase button:not(:disabled) * {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
header button:disabled,
|
|
||||||
header button:disabled *,
|
|
||||||
.mobile-menu-panel button:disabled,
|
|
||||||
.mobile-menu-panel button:disabled * {
|
|
||||||
cursor: wait;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hexagon background animations */
|
/* Hexagon background animations */
|
||||||
@keyframes hex-drift {
|
@keyframes hex-drift {
|
||||||
from {
|
from {
|
||||||
|
|
@ -90,6 +70,15 @@ header button:disabled *,
|
||||||
background: linear-gradient(180deg, #f3efe8 0%, #fafaf9 36%, #eef7f3 100%);
|
background: linear-gradient(180deg, #f3efe8 0%, #fafaf9 36%, #eef7f3 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.home-page-scroll {
|
||||||
|
--home-scroll-y: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-hero-hex-parallax {
|
||||||
|
transform: translate3d(0, calc(var(--home-scroll-y, 0px) * 0.18), 0);
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
.home-content-surface::before {
|
.home-content-surface::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import { LayerExtension } from '@deck.gl/core';
|
import { LayerExtension } from '@deck.gl/core';
|
||||||
import { ENUM_PALETTE } from './consts';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LayerExtension that turns polygon fills into pie charts.
|
* LayerExtension that turns polygon fills into pie charts.
|
||||||
|
|
@ -12,7 +11,7 @@ import { ENUM_PALETTE } from './consts';
|
||||||
* - stepMode:'dynamic' handles per-instance counting automatically.
|
* - stepMode:'dynamic' handles per-instance counting automatically.
|
||||||
* - isEnabled() restricts to SolidPolygonLayer (fill) sublayers only.
|
* - isEnabled() restricts to SolidPolygonLayer (fill) sublayers only.
|
||||||
*
|
*
|
||||||
* Accepts an optional custom palette in the constructor for per-feature color overrides.
|
* Accepts the configured enum palette in the constructor.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function paletteToGlsl(palette: [number, number, number][]): string {
|
function paletteToGlsl(palette: [number, number, number][]): string {
|
||||||
|
|
@ -35,9 +34,9 @@ export class PieHexExtension extends LayerExtension {
|
||||||
|
|
||||||
private paletteGlsl: string;
|
private paletteGlsl: string;
|
||||||
|
|
||||||
constructor(palette?: [number, number, number][]) {
|
constructor(palette: [number, number, number][]) {
|
||||||
super();
|
super();
|
||||||
this.paletteGlsl = paletteToGlsl(palette ?? ENUM_PALETTE);
|
this.paletteGlsl = paletteToGlsl(palette);
|
||||||
}
|
}
|
||||||
|
|
||||||
isEnabled(layer: any): boolean {
|
isEnabled(layer: any): boolean {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,12 @@ import type { FeatureMeta } from '../types';
|
||||||
import { apiUrl, assertOk, buildFilterString, isAbortError } from './api';
|
import { apiUrl, assertOk, buildFilterString, isAbortError } from './api';
|
||||||
import { createSchoolFilterKey } from './school-filter';
|
import { createSchoolFilterKey } from './school-filter';
|
||||||
import { createSpecificCrimeFilterKey } from './crime-filter';
|
import { createSpecificCrimeFilterKey } from './crime-filter';
|
||||||
|
import { createEthnicityFilterKey } from './ethnicity-filter';
|
||||||
|
import {
|
||||||
|
POI_COUNT_2KM_FILTER_NAME,
|
||||||
|
createPoiDistanceFilterKey,
|
||||||
|
createPoiFilterKey,
|
||||||
|
} from './poi-distance-filter';
|
||||||
|
|
||||||
describe('api utilities', () => {
|
describe('api utilities', () => {
|
||||||
it('builds API URLs from endpoint names, paths, and params', () => {
|
it('builds API URLs from endpoint names, paths, and params', () => {
|
||||||
|
|
@ -99,4 +105,52 @@ describe('api utilities', () => {
|
||||||
)
|
)
|
||||||
).toBe('Burglary (avg/yr):0:5;;Vehicle crime (avg/yr):1:10');
|
).toBe('Burglary (avg/yr):0:5;;Vehicle crime (avg/yr):1:10');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('deduplicates repeated ethnicity filters to the strictest backend range', () => {
|
||||||
|
const features: FeatureMeta[] = [{ name: '% White', type: 'numeric', min: 0, max: 100 }];
|
||||||
|
|
||||||
|
expect(
|
||||||
|
buildFilterString(
|
||||||
|
{
|
||||||
|
[createEthnicityFilterKey('% White', 1)]: [10, 90],
|
||||||
|
[createEthnicityFilterKey('% White', 2)]: [20, 80],
|
||||||
|
},
|
||||||
|
features
|
||||||
|
)
|
||||||
|
).toBe('% White:20:80');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serializes POI distance filters using their selected backend feature', () => {
|
||||||
|
const features: FeatureMeta[] = [
|
||||||
|
{ name: 'Distance to nearest park (km)', type: 'numeric', min: 0, max: 2 },
|
||||||
|
{ name: 'Distance to nearest Tesco (km)', type: 'numeric', min: 0, max: 5 },
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(
|
||||||
|
buildFilterString(
|
||||||
|
{
|
||||||
|
[createPoiDistanceFilterKey('Distance to nearest park (km)', 1)]: [0, 0.5],
|
||||||
|
[createPoiDistanceFilterKey('Distance to nearest Tesco (km)', 2)]: [0, 1],
|
||||||
|
},
|
||||||
|
features
|
||||||
|
)
|
||||||
|
).toBe('Distance to nearest park (km):0:0.5;;Distance to nearest Tesco (km):0:1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serializes POI count filters using their selected backend feature', () => {
|
||||||
|
const features: FeatureMeta[] = [
|
||||||
|
{ name: 'Number of Cafe POIs within 2km', type: 'numeric', min: 0, max: 20 },
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(
|
||||||
|
buildFilterString(
|
||||||
|
{
|
||||||
|
[createPoiFilterKey(POI_COUNT_2KM_FILTER_NAME, 'Number of Cafe POIs within 2km', 1)]: [
|
||||||
|
2, 10,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
features
|
||||||
|
)
|
||||||
|
).toBe('Number of Cafe POIs within 2km:2:10');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import { INITIAL_RETRY_MS, MAX_RETRY_MS } from './consts';
|
||||||
import pb from './pocketbase';
|
import pb from './pocketbase';
|
||||||
import { getSchoolBackendFeatureName } from './school-filter';
|
import { getSchoolBackendFeatureName } from './school-filter';
|
||||||
import { getSpecificCrimeFeatureName } from './crime-filter';
|
import { getSpecificCrimeFeatureName } from './crime-filter';
|
||||||
|
import { getEthnicityFeatureName } from './ethnicity-filter';
|
||||||
|
import { getPoiDistanceFeatureName } from './poi-distance-filter';
|
||||||
|
|
||||||
export function logNonAbortError(label: string, error: unknown): void {
|
export function logNonAbortError(label: string, error: unknown): void {
|
||||||
if (error instanceof Error && error.name === 'AbortError') {
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
|
@ -89,7 +91,11 @@ export function buildFilterString(
|
||||||
for (const [name, value] of entries) {
|
for (const [name, value] of entries) {
|
||||||
if (name === exclude) continue;
|
if (name === exclude) continue;
|
||||||
const backendName =
|
const backendName =
|
||||||
getSchoolBackendFeatureName(name) ?? getSpecificCrimeFeatureName(name) ?? name;
|
getSchoolBackendFeatureName(name) ??
|
||||||
|
getSpecificCrimeFeatureName(name) ??
|
||||||
|
getEthnicityFeatureName(name) ??
|
||||||
|
getPoiDistanceFeatureName(name) ??
|
||||||
|
name;
|
||||||
const prev = merged.get(backendName);
|
const prev = merged.get(backendName);
|
||||||
if (
|
if (
|
||||||
prev &&
|
prev &&
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ export const PARTY_FEATURE_GRADIENTS: Record<string, GradientStop[]> = {
|
||||||
'% Liberal Democrat': partyGradient([255, 100, 0]), // Liberal Democrat orange
|
'% Liberal Democrat': partyGradient([255, 100, 0]), // Liberal Democrat orange
|
||||||
'% Reform UK': partyGradient([18, 182, 207]), // Reform UK cyan
|
'% Reform UK': partyGradient([18, 182, 207]), // Reform UK cyan
|
||||||
'% Green': partyGradient([106, 176, 35]), // Green Party green
|
'% Green': partyGradient([106, 176, 35]), // Green Party green
|
||||||
'% Other parties': partyGradient([107, 114, 128]), // neutral fallback for grouped parties
|
'% Other parties': partyGradient([107, 114, 128]), // neutral color for grouped parties
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PARTY_FEATURE_COLORS: Record<string, string> = Object.fromEntries(
|
export const PARTY_FEATURE_COLORS: Record<string, string> = Object.fromEntries(
|
||||||
|
|
@ -127,9 +127,6 @@ export const POI_GROUP_COLORS: Record<string, [number, number, number]> = {
|
||||||
Shops: [99, 102, 241],
|
Shops: [99, 102, 241],
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Default color for unknown POI groups */
|
|
||||||
export const POI_DEFAULT_COLOR: [number, number, number] = [107, 114, 128];
|
|
||||||
|
|
||||||
/** POI category → icon/logo URL for branded and transport categories */
|
/** POI category → icon/logo URL for branded and transport categories */
|
||||||
export const POI_CATEGORY_LOGOS: Record<string, string> = {
|
export const POI_CATEGORY_LOGOS: Record<string, string> = {
|
||||||
Airport: '/assets/twemoji/2708.png',
|
Airport: '/assets/twemoji/2708.png',
|
||||||
|
|
@ -152,22 +149,22 @@ export const POI_CATEGORY_LOGOS: Record<string, string> = {
|
||||||
'Co-op': '/assets/poi-icons/logos/coop.svg',
|
'Co-op': '/assets/poi-icons/logos/coop.svg',
|
||||||
COOK: '/assets/poi-icons/brands_2024/cook.svg',
|
COOK: '/assets/poi-icons/brands_2024/cook.svg',
|
||||||
'Convenience Store': '/assets/twemoji/1f3ea.png',
|
'Convenience Store': '/assets/twemoji/1f3ea.png',
|
||||||
Costco: '/assets/poi-icons/brands/costco.svg',
|
Costco: '/assets/poi-icons/logos/costco.svg',
|
||||||
'Deli & Specialty': '/assets/twemoji/1f9c6.png',
|
'Deli & Specialty': '/assets/twemoji/1f9c6.png',
|
||||||
'Dunnes Stores': '/assets/poi-icons/brands_2024/dunnes_stores.svg',
|
'Dunnes Stores': '/assets/poi-icons/brands_2024/dunnes_stores.svg',
|
||||||
Farmfoods: '/assets/poi-icons/brands_2023/supermarkets/farmfoods.svg',
|
Farmfoods: '/assets/poi-icons/brands_2023/supermarkets/farmfoods.svg',
|
||||||
Ferry: '/assets/twemoji/26f4.png',
|
Ferry: '/assets/twemoji/26f4.png',
|
||||||
Greengrocer: '/assets/twemoji/1f96c.png',
|
Greengrocer: '/assets/twemoji/1f96c.png',
|
||||||
'Heron Foods': '/assets/poi-icons/brands_2023/supermarkets/heron_foods.svg',
|
'Heron Foods': '/assets/poi-icons/brands_2023/supermarkets/heron_foods.svg',
|
||||||
Iceland: '/assets/poi-icons/logos/iceland.svg',
|
Iceland: '/assets/poi-icons/brands_2024/iceland.svg',
|
||||||
Lidl: '/assets/poi-icons/logos/lidl.svg',
|
Lidl: '/assets/poi-icons/logos/lidl.svg',
|
||||||
Makro: '/assets/poi-icons/brands_2024/makro.svg',
|
Makro: '/assets/poi-icons/brands_2024/makro.svg',
|
||||||
'M&S': '/assets/poi-icons/brands/mns.svg',
|
'M&S': '/assets/poi-icons/brands_2024/mns.svg',
|
||||||
'M&S Clothing': '/assets/poi-icons/brands/mns_high_street.svg',
|
'M&S Clothing': '/assets/poi-icons/brands_2024/mns.svg',
|
||||||
'M&S Food': '/assets/poi-icons/brands/mns_food.svg',
|
'M&S Food': '/assets/poi-icons/visuals/mns.svg',
|
||||||
'M&S Hospital': '/assets/poi-icons/brands/mns_hospital.svg',
|
'M&S Hospital': '/assets/poi-icons/brands_2024/mns.svg',
|
||||||
'M&S MSA': '/assets/poi-icons/brands/mns_moto.svg',
|
'M&S MSA': '/assets/poi-icons/brands_2024/mns.svg',
|
||||||
'M&S Outlet': '/assets/poi-icons/brands/mns_outlet.svg',
|
'M&S Outlet': '/assets/poi-icons/brands_2024/mns.svg',
|
||||||
Morrisons: '/assets/poi-icons/logos/morrisons.svg',
|
Morrisons: '/assets/poi-icons/logos/morrisons.svg',
|
||||||
'Morrisons Daily': '/assets/poi-icons/brands_2024/morrisons_daily.svg',
|
'Morrisons Daily': '/assets/poi-icons/brands_2024/morrisons_daily.svg',
|
||||||
'Off-Licence': '/assets/twemoji/1f377.png',
|
'Off-Licence': '/assets/twemoji/1f377.png',
|
||||||
|
|
@ -181,10 +178,10 @@ export const POI_CATEGORY_LOGOS: Record<string, string> = {
|
||||||
'Tesco Express': '/assets/poi-icons/logos/tesco_express.svg',
|
'Tesco Express': '/assets/poi-icons/logos/tesco_express.svg',
|
||||||
'Tesco Extra': '/assets/poi-icons/logos/tesco_extra.svg',
|
'Tesco Extra': '/assets/poi-icons/logos/tesco_extra.svg',
|
||||||
'Taxi rank': '/assets/twemoji/1f695.png',
|
'Taxi rank': '/assets/twemoji/1f695.png',
|
||||||
'The Food Warehouse': '/assets/poi-icons/logos/iceland.svg',
|
'The Food Warehouse': '/assets/poi-icons/logos/the_food_warehouse.png',
|
||||||
'Tube station': '/assets/poi-icons/public_transport/london_tube.svg',
|
'Tube station': '/assets/poi-icons/public_transport/london_tube.svg',
|
||||||
Waitrose: '/assets/poi-icons/logos/waitrose.svg',
|
Waitrose: '/assets/poi-icons/logos/waitrose.svg',
|
||||||
'Little Waitrose': '/assets/poi-icons/brands/little_waitrose.svg',
|
'Little Waitrose': '/assets/poi-icons/brands_2023/supermarkets/little_waitrose.svg',
|
||||||
'Whole Foods Market': '/assets/poi-icons/brands_2024/wholefoods.svg',
|
'Whole Foods Market': '/assets/poi-icons/brands_2024/wholefoods.svg',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -329,9 +326,8 @@ export const ENUM_PALETTE: [number, number, number][] = [
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-feature color overrides for enum values on the map and dashboard.
|
* Per-feature color definitions for enum values on the map and dashboard.
|
||||||
* Keys are feature names (as returned by the server), values map enum value → RGB.
|
* Keys are feature names (as returned by the server), values map enum value → RGB.
|
||||||
* Any value not listed falls back to ENUM_PALETTE by index.
|
|
||||||
*/
|
*/
|
||||||
export const ENUM_COLOR_OVERRIDES: Record<string, Record<string, [number, number, number]>> = {
|
export const ENUM_COLOR_OVERRIDES: Record<string, Record<string, [number, number, number]>> = {
|
||||||
'Property type': {
|
'Property type': {
|
||||||
|
|
@ -341,49 +337,105 @@ export const ENUM_COLOR_OVERRIDES: Record<string, Record<string, [number, number
|
||||||
'Flats/Maisonettes': [236, 72, 153], // pink
|
'Flats/Maisonettes': [236, 72, 153], // pink
|
||||||
Other: [107, 114, 128], // gray
|
Other: [107, 114, 128], // gray
|
||||||
},
|
},
|
||||||
|
'Leasehold/Freehold': {
|
||||||
|
Freehold: [59, 130, 246],
|
||||||
|
Leasehold: [245, 158, 11],
|
||||||
|
},
|
||||||
|
'Former council house': {
|
||||||
|
Yes: [239, 68, 68],
|
||||||
|
No: [34, 197, 94],
|
||||||
|
},
|
||||||
|
'Current energy rating': {
|
||||||
|
A: [22, 163, 74],
|
||||||
|
B: [132, 204, 22],
|
||||||
|
C: [234, 179, 8],
|
||||||
|
D: [245, 158, 11],
|
||||||
|
E: [249, 115, 22],
|
||||||
|
F: [239, 68, 68],
|
||||||
|
G: [126, 34, 206],
|
||||||
|
},
|
||||||
|
'Potential energy rating': {
|
||||||
|
A: [22, 163, 74],
|
||||||
|
B: [132, 204, 22],
|
||||||
|
C: [234, 179, 8],
|
||||||
|
D: [245, 158, 11],
|
||||||
|
E: [249, 115, 22],
|
||||||
|
F: [239, 68, 68],
|
||||||
|
G: [126, 34, 206],
|
||||||
|
},
|
||||||
|
'Max available download speed (Mbps)': {
|
||||||
|
'10': [107, 114, 128],
|
||||||
|
'30': [245, 158, 11],
|
||||||
|
'100': [59, 130, 246],
|
||||||
|
'300': [20, 184, 166],
|
||||||
|
'1000': [34, 197, 94],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a 10-color palette for a given feature, using overrides where defined.
|
* Build the 10-color shader palette for a given enum feature.
|
||||||
* Returns the default ENUM_PALETTE when no overrides exist.
|
* The trailing slots are invisible for features with fewer than 10 enum values.
|
||||||
*/
|
*/
|
||||||
export function getEnumPaletteForFeature(
|
export function getEnumPaletteForFeature(
|
||||||
featureName: string | null,
|
featureName: string,
|
||||||
values?: string[]
|
values: string[]
|
||||||
): [number, number, number][] {
|
): [number, number, number][] {
|
||||||
if (!featureName || !values) return ENUM_PALETTE;
|
|
||||||
const overrides = ENUM_COLOR_OVERRIDES[featureName];
|
const overrides = ENUM_COLOR_OVERRIDES[featureName];
|
||||||
if (!overrides) return ENUM_PALETTE;
|
if (!overrides) {
|
||||||
|
throw new Error(`Missing enum color definitions for '${featureName}'`);
|
||||||
|
}
|
||||||
|
|
||||||
const palette: [number, number, number][] = [];
|
const palette: [number, number, number][] = [];
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
if (i < values.length && overrides[values[i]]) {
|
if (i < values.length) {
|
||||||
palette.push(overrides[values[i]]);
|
const color = overrides[values[i]];
|
||||||
|
if (!color) {
|
||||||
|
throw new Error(`Missing enum color for '${featureName}' value '${values[i]}'`);
|
||||||
|
}
|
||||||
|
palette.push(color);
|
||||||
} else {
|
} else {
|
||||||
palette.push(ENUM_PALETTE[i % ENUM_PALETTE.length]);
|
palette.push([0, 0, 0]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return palette;
|
return palette;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Look up override color for a specific enum value, or null if none. */
|
/** Look up the configured color for a specific enum value. */
|
||||||
export function getEnumValueColor(
|
export function getEnumValueColor(
|
||||||
featureName: string,
|
featureName: string,
|
||||||
valueName: string
|
valueName: string
|
||||||
): [number, number, number] | null {
|
): [number, number, number] {
|
||||||
return ENUM_COLOR_OVERRIDES[featureName]?.[valueName] ?? null;
|
const color = ENUM_COLOR_OVERRIDES[featureName]?.[valueName];
|
||||||
|
if (!color) {
|
||||||
|
throw new Error(`Missing enum color for '${featureName}' value '${valueName}'`);
|
||||||
|
}
|
||||||
|
return color;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Colors for stacked bar segments */
|
/** Explicit colors for stacked bar segments. */
|
||||||
export const SEGMENT_COLORS = [
|
export const STACKED_SEGMENT_COLORS: Record<string, string> = {
|
||||||
'#ef4444', // red-500
|
'Violence and sexual offences (avg/yr)': '#ef4444',
|
||||||
'#f97316', // orange-500
|
'Robbery (avg/yr)': '#f97316',
|
||||||
'#eab308', // yellow-500
|
'Burglary (avg/yr)': '#eab308',
|
||||||
'#22c55e', // green-500
|
'Possession of weapons (avg/yr)': '#8b5cf6',
|
||||||
'#14b8a6', // teal-500
|
'Anti-social behaviour (avg/yr)': '#14b8a6',
|
||||||
'#06b6d4', // cyan-500
|
'Criminal damage and arson (avg/yr)': '#f97316',
|
||||||
'#3b82f6', // blue-500
|
'Shoplifting (avg/yr)': '#ec4899',
|
||||||
'#8b5cf6', // violet-500
|
'Bicycle theft (avg/yr)': '#22c55e',
|
||||||
'#d946ef', // fuchsia-500
|
'Theft from the person (avg/yr)': '#d946ef',
|
||||||
'#ec4899', // pink-500
|
'Other theft (avg/yr)': '#06b6d4',
|
||||||
];
|
'Vehicle crime (avg/yr)': '#3b82f6',
|
||||||
|
'Public order (avg/yr)': '#8b5cf6',
|
||||||
|
'Drugs (avg/yr)': '#22c55e',
|
||||||
|
'Other crime (avg/yr)': '#6b7280',
|
||||||
|
'% White': '#3b82f6',
|
||||||
|
'% South Asian': '#f97316',
|
||||||
|
'% East Asian': '#eab308',
|
||||||
|
'% Black': '#8b5cf6',
|
||||||
|
'% Mixed': '#14b8a6',
|
||||||
|
'% Other': '#6b7280',
|
||||||
|
'Anti-social': '#14b8a6',
|
||||||
|
Vehicle: '#3b82f6',
|
||||||
|
Burglary: '#eab308',
|
||||||
|
Other: '#6b7280',
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,10 @@ export function groupFeaturesByCategory(features: FeatureMeta[]): FeatureGroup[]
|
||||||
const seen = new Map<string, FeatureMeta[]>();
|
const seen = new Map<string, FeatureMeta[]>();
|
||||||
|
|
||||||
for (const feature of features) {
|
for (const feature of features) {
|
||||||
const groupName = feature.group || 'Other';
|
if (!feature.group) {
|
||||||
|
throw new Error(`Feature '${feature.name}' is missing its group`);
|
||||||
|
}
|
||||||
|
const groupName = feature.group;
|
||||||
let arr = seen.get(groupName);
|
let arr = seen.get(groupName);
|
||||||
if (!arr) {
|
if (!arr) {
|
||||||
arr = [];
|
arr = [];
|
||||||
|
|
|
||||||
|
|
@ -38,18 +38,21 @@ export function formatValue(value: number, fmt?: ValueFormat): string {
|
||||||
return `${p}${value.toFixed(1)}${s}`;
|
return `${p}${value.toFixed(1)}${s}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatFilterValue(value: number, raw?: boolean): string {
|
export function formatFilterValue(value: number, rawOrFmt?: boolean | ValueFormat): string {
|
||||||
if (raw) return Math.round(value).toString();
|
const fmt = typeof rawOrFmt === 'object' ? rawOrFmt : { raw: rawOrFmt };
|
||||||
|
const p = fmt?.prefix ?? '';
|
||||||
|
const s = fmt?.suffix ?? '';
|
||||||
|
if (fmt?.raw) return `${p}${Math.round(value)}${s}`;
|
||||||
if (usesChineseNumberUnits()) {
|
if (usesChineseNumberUnits()) {
|
||||||
const chineseCompactValue = formatChineseCompactNumber(value);
|
const chineseCompactValue = formatChineseCompactNumber(value);
|
||||||
if (chineseCompactValue) return chineseCompactValue;
|
if (chineseCompactValue) return `${p}${chineseCompactValue}${s}`;
|
||||||
if (Number.isInteger(value)) return value.toString();
|
if (Number.isInteger(value)) return `${p}${value}${s}`;
|
||||||
return value.toFixed(2);
|
return `${p}${value.toFixed(2)}${s}`;
|
||||||
}
|
}
|
||||||
if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
|
if (Math.abs(value) >= 1_000_000) return `${p}${(value / 1_000_000).toFixed(1)}M${s}`;
|
||||||
if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(1)}k`;
|
if (Math.abs(value) >= 1_000) return `${p}${(value / 1_000).toFixed(1)}k${s}`;
|
||||||
if (Number.isInteger(value)) return value.toString();
|
if (Number.isInteger(value)) return `${p}${value}${s}`;
|
||||||
return value.toFixed(2);
|
return `${p}${value.toFixed(2)}${s}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parse a user-typed value like "250k", "1.2M", "£300000", "50 sqm" back to a number. */
|
/** Parse a user-typed value like "250k", "1.2M", "£300000", "50 sqm" back to a number. */
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
ShieldIcon,
|
ShieldIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
ShoppingBagIcon,
|
ShoppingBagIcon,
|
||||||
|
MapPinIcon,
|
||||||
TreeIcon,
|
TreeIcon,
|
||||||
TagIcon,
|
TagIcon,
|
||||||
} from '../components/ui/icons';
|
} from '../components/ui/icons';
|
||||||
|
|
@ -18,6 +19,7 @@ const GROUP_ICONS: Record<string, ComponentType<{ className?: string }>> = {
|
||||||
Deprivation: ChartBarIcon,
|
Deprivation: ChartBarIcon,
|
||||||
Crime: ShieldIcon,
|
Crime: ShieldIcon,
|
||||||
Demographics: UsersIcon,
|
Demographics: UsersIcon,
|
||||||
|
'Nearby POIs': MapPinIcon,
|
||||||
Amenities: ShoppingBagIcon,
|
Amenities: ShoppingBagIcon,
|
||||||
Environment: TreeIcon,
|
Environment: TreeIcon,
|
||||||
Property: TagIcon,
|
Property: TagIcon,
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { existsSync } from 'fs';
|
import { existsSync, readFileSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DENSITY_GRADIENT,
|
DENSITY_GRADIENT,
|
||||||
ENUM_PALETTE,
|
ENUM_PALETTE,
|
||||||
FEATURE_GRADIENT,
|
FEATURE_GRADIENT,
|
||||||
|
MAP_BOUNDS,
|
||||||
POI_CATEGORY_LOGOS,
|
POI_CATEGORY_LOGOS,
|
||||||
SMALLEST_VISIBLE_HEXAGON_RESOLUTION,
|
SMALLEST_VISIBLE_HEXAGON_RESOLUTION,
|
||||||
} from './consts';
|
} from './consts';
|
||||||
|
|
@ -13,6 +14,8 @@ import {
|
||||||
emojiToTwemojiUrl,
|
emojiToTwemojiUrl,
|
||||||
enumIndexToColor,
|
enumIndexToColor,
|
||||||
getBoundsFromViewState,
|
getBoundsFromViewState,
|
||||||
|
getBoundsWithBottomScreenInset,
|
||||||
|
getLatitudeAtVerticalPixelOffset,
|
||||||
getFeatureFillColor,
|
getFeatureFillColor,
|
||||||
getMapCenterForTargetScreenPoint,
|
getMapCenterForTargetScreenPoint,
|
||||||
getPoiIconUrl,
|
getPoiIconUrl,
|
||||||
|
|
@ -48,21 +51,41 @@ describe('map utilities', () => {
|
||||||
expect(centered.latitude).toBeLessThan(51.5);
|
expect(centered.latitude).toBeLessThan(51.5);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('builds twemoji URLs and wraps enum colors', () => {
|
it('expands the southern map bound by a covered screen area', () => {
|
||||||
expect(emojiToTwemojiUrl('🛒')).toBe('/assets/twemoji/1f6d2.png');
|
const shiftedSouth = getLatitudeAtVerticalPixelOffset(MAP_BOUNDS[1], 5.5, 320);
|
||||||
expect(emojiToTwemojiUrl('')).toBe('/assets/twemoji/1f4cd.png');
|
const shiftedNorth = getLatitudeAtVerticalPixelOffset(MAP_BOUNDS[1], 5.5, -320);
|
||||||
expect(enumIndexToColor(ENUM_PALETTE.length)).toEqual(ENUM_PALETTE[0]);
|
const expandedBounds = getBoundsWithBottomScreenInset(MAP_BOUNDS, 5.5, 320);
|
||||||
|
|
||||||
|
expect(shiftedSouth).toBeLessThan(MAP_BOUNDS[1]);
|
||||||
|
expect(shiftedNorth).toBeGreaterThan(MAP_BOUNDS[1]);
|
||||||
|
expect(expandedBounds[0]).toBe(MAP_BOUNDS[0]);
|
||||||
|
expect(expandedBounds[1]).toBeCloseTo(shiftedSouth, 6);
|
||||||
|
expect(expandedBounds[2]).toBe(MAP_BOUNDS[2]);
|
||||||
|
expect(expandedBounds[3]).toBe(MAP_BOUNDS[3]);
|
||||||
|
expect(getBoundsWithBottomScreenInset(MAP_BOUNDS, 5.5, 0)).toEqual(MAP_BOUNDS);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prefers POI category logos before falling back to emoji icons', () => {
|
it('builds twemoji URLs and wraps enum colors', () => {
|
||||||
expect(getPoiIconUrl('Waitrose', '🛒')).toBe('/assets/poi-icons/brands/waitrose_24px.svg');
|
expect(emojiToTwemojiUrl('🛒')).toBe('/assets/twemoji/1f6d2.png');
|
||||||
|
expect(() => emojiToTwemojiUrl('')).toThrow('Cannot build a Twemoji URL without an emoji');
|
||||||
|
expect(enumIndexToColor(ENUM_PALETTE.length, ENUM_PALETTE)).toEqual(ENUM_PALETTE[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves POI category logos and rejects unknown icon categories', () => {
|
||||||
|
expect(getPoiIconUrl('Waitrose', '🛒')).toBe('/assets/poi-icons/logos/waitrose.svg');
|
||||||
expect(getPoiIconUrl('Iceland', '🛒', 'The Food Warehouse')).toBe(
|
expect(getPoiIconUrl('Iceland', '🛒', 'The Food Warehouse')).toBe(
|
||||||
'/assets/poi-icons/brands/iceland_food_warehouse_24px.svg'
|
'/assets/poi-icons/logos/the_food_warehouse.png'
|
||||||
);
|
);
|
||||||
expect(getPoiIconUrl("Sainsbury's", '🛒', undefined, 'Sainsburys Earlsfield Local')).toBe(
|
expect(getPoiIconUrl("Sainsbury's", '🛒', undefined, 'Sainsburys Earlsfield Local')).toBe(
|
||||||
'/assets/poi-icons/brands/sainsburys_local_24px.svg'
|
'/assets/poi-icons/brands_2024/sainsburys_local.svg'
|
||||||
|
);
|
||||||
|
expect(getPoiIconUrl('Costco', '🛒')).toBe('/assets/poi-icons/logos/costco.svg');
|
||||||
|
expect(getPoiIconUrl('M&S', '🛒', undefined, 'M&S Simply Food')).toBe(
|
||||||
|
'/assets/poi-icons/visuals/mns.svg'
|
||||||
|
);
|
||||||
|
expect(() => getPoiIconUrl('Unknown category', '🛒')).toThrow(
|
||||||
|
"Missing POI icon for category 'Unknown category'"
|
||||||
);
|
);
|
||||||
expect(getPoiIconUrl('Unknown category', '🛒')).toBe('/assets/twemoji/1f6d2.png');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps POI icon URLs bundled locally', () => {
|
it('keeps POI icon URLs bundled locally', () => {
|
||||||
|
|
@ -74,6 +97,43 @@ describe('map utilities', () => {
|
||||||
).toEqual([]);
|
).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not use pin-shaped SVGs for branded POI logos', () => {
|
||||||
|
const pinSignatures = ['viewBox="0 0 400 520"', 'C 18.914 185.931'];
|
||||||
|
const svgUrls = [
|
||||||
|
...new Set(
|
||||||
|
Object.values(POI_CATEGORY_LOGOS)
|
||||||
|
.filter((url) => url.startsWith('/assets/poi-icons/'))
|
||||||
|
.filter((url) => url.endsWith('.svg'))
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(
|
||||||
|
svgUrls.filter((url) => {
|
||||||
|
const content = readFileSync(join(process.cwd(), 'public', url.slice(1)), 'utf8');
|
||||||
|
return pinSignatures.some((signature) => content.includes(signature));
|
||||||
|
})
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps bundled SVG logos large enough for the map icon atlas', () => {
|
||||||
|
const svgUrls = [
|
||||||
|
...new Set(
|
||||||
|
Object.values(POI_CATEGORY_LOGOS)
|
||||||
|
.filter((url) => url.startsWith('/assets/poi-icons/'))
|
||||||
|
.filter((url) => url.endsWith('.svg'))
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(
|
||||||
|
svgUrls.filter((url) => {
|
||||||
|
const content = readFileSync(join(process.cwd(), 'public', url.slice(1)), 'utf8');
|
||||||
|
const width = Number(content.match(/width="([0-9.]+)/)?.[1]);
|
||||||
|
const height = Number(content.match(/height="([0-9.]+)/)?.[1]);
|
||||||
|
return Math.max(width, height) < 256;
|
||||||
|
})
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it('returns fallback, filtered, enum, feature, and density colors', () => {
|
it('returns fallback, filtered, enum, feature, and density colors', () => {
|
||||||
expect(
|
expect(
|
||||||
getFeatureFillColor(
|
getFeatureFillColor(
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,7 @@ export const SEO_LANDING_PAGES: Record<SeoLandingKey, SeoLandingContent> = {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Separate cheap from good value',
|
title: 'Separate cheap from good value',
|
||||||
body: 'A lower price can reflect smaller homes, weaker transport, more noise, or fewer local services. The map keeps those trade-offs visible so the cheapest postcode is not automatically treated as the best option.',
|
body: 'A lower price can reflect smaller homes, weaker transport, more noise, or fewer local services. The map keeps those trade-offs visible so the cheapest postcode isn’t automatically treated as the best option.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
sections: [
|
sections: [
|
||||||
|
|
@ -110,7 +110,7 @@ export const SEO_LANDING_PAGES: Record<SeoLandingKey, SeoLandingContent> = {
|
||||||
methodology: [
|
methodology: [
|
||||||
{
|
{
|
||||||
title: 'What the price data is for',
|
title: 'What the price data is for',
|
||||||
body: 'Use the map to compare areas and spot search candidates. It is not a valuation, mortgage decision, survey, legal search, or live listing feed.',
|
body: 'Use the map to compare areas and spot search candidates. It isn’t a valuation, mortgage decision, survey, legal search, or live listing feed.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'How to validate a promising area',
|
title: 'How to validate a promising area',
|
||||||
|
|
@ -121,7 +121,7 @@ export const SEO_LANDING_PAGES: Record<SeoLandingKey, SeoLandingContent> = {
|
||||||
{
|
{
|
||||||
question: 'Is this a replacement for Rightmove or Zoopla?',
|
question: 'Is this a replacement for Rightmove or Zoopla?',
|
||||||
answer:
|
answer:
|
||||||
'No. Use it before and alongside listing portals. Perfect Postcode helps decide where to look; listing portals show what is currently for sale.',
|
'No. Use it before and alongside listing portals. Perfect Postcode helps decide where to look; listing portals show what’s currently for sale.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'Can I compare price with schools or commute time?',
|
question: 'Can I compare price with schools or commute time?',
|
||||||
|
|
@ -232,11 +232,11 @@ export const SEO_LANDING_PAGES: Record<SeoLandingKey, SeoLandingContent> = {
|
||||||
workflows: [
|
workflows: [
|
||||||
{
|
{
|
||||||
title: 'Start with the destination that matters',
|
title: 'Start with the destination that matters',
|
||||||
body: 'Choose a commute destination, transport mode, and time range, then add the property filters. This prevents a cheap-looking area from reaching the shortlist if the daily journey does not work.',
|
body: 'Choose a commute destination, transport mode, and time range, then add the property filters. This prevents a cheap-looking area from reaching the shortlist if the daily journey doesn’t work.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Compare the commute against the rest of daily life',
|
title: 'Compare the commute against the rest of daily life',
|
||||||
body: 'A fast commute is not enough if the property size, school context, safety threshold, broadband, or road-noise exposure do not fit. The map keeps those signals side by side.',
|
body: 'A fast commute isn’t enough if the property size, school context, safety threshold, broadband, or road-noise exposure don’t fit. The map keeps those signals side by side.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
sections: [
|
sections: [
|
||||||
|
|
@ -397,15 +397,15 @@ export const SEO_LANDING_PAGES: Record<SeoLandingKey, SeoLandingContent> = {
|
||||||
body: 'A postcode check can surface price context, environmental signals, nearby amenities, and other local indicators that are easy to miss in a listing.',
|
body: 'A postcode check can surface price context, environmental signals, nearby amenities, and other local indicators that are easy to miss in a listing.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'What a postcode check cannot prove',
|
title: 'What a postcode check can’t prove',
|
||||||
body: 'It cannot confirm the condition of a home, future development, legal title, lender requirements, or current street-level experience. Those still need direct checks.',
|
body: 'It can’t confirm the condition of a home, future development, legal title, lender requirements, or current street-level experience. Those still need direct checks.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
faq: [
|
faq: [
|
||||||
{
|
{
|
||||||
question: 'Can I use the checker before a viewing?',
|
question: 'Can I use the checker before a viewing?',
|
||||||
answer:
|
answer:
|
||||||
'Yes. That is one of the main use cases: screen the postcode first, then decide whether the viewing is worth the time.',
|
'Yes. That’s one of the main use cases: screen the postcode first, then decide whether the viewing is worth the time.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'Does the checker include exact property condition?',
|
question: 'Does the checker include exact property condition?',
|
||||||
|
|
@ -654,7 +654,7 @@ export const SEO_CONTENT_PAGES: Record<SeoContentKey, SeoContentPage> = {
|
||||||
metaDescription:
|
metaDescription:
|
||||||
'Understand how to use postcode filters, property estimates, travel-time data, school context and local signals as a home-buying shortlist tool.',
|
'Understand how to use postcode filters, property estimates, travel-time data, school context and local signals as a home-buying shortlist tool.',
|
||||||
intro:
|
intro:
|
||||||
'Perfect Postcode is designed to make area shortlisting more evidence-led. It does not replace estate agents, surveyors, conveyancers, lenders, school admissions teams, or local authority checks.',
|
'Perfect Postcode is designed to make area shortlisting more evidence-led. It doesn’t replace estate agents, surveyors, conveyancers, lenders, school admissions teams, or local authority checks.',
|
||||||
sections: [
|
sections: [
|
||||||
{
|
{
|
||||||
title: 'Start with hard constraints',
|
title: 'Start with hard constraints',
|
||||||
|
|
@ -665,7 +665,7 @@ export const SEO_CONTENT_PAGES: Record<SeoContentKey, SeoContentPage> = {
|
||||||
body: 'After filtering, colour the remaining map by one signal at a time: price per square metre, road noise, school context, commute time, broadband, or crime. This makes trade-offs easier to discuss.',
|
body: 'After filtering, colour the remaining map by one signal at a time: price per square metre, road noise, school context, commute time, broadband, or crime. This makes trade-offs easier to discuss.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Measure what is working',
|
title: 'Measure what’s working',
|
||||||
body: 'Use Search Console and analytics to track which public pages are indexed, which queries produce impressions, and which pages convert visitors into dashboard exploration. Review Core Web Vitals after every substantial frontend change.',
|
body: 'Use Search Console and analytics to track which public pages are indexed, which queries produce impressions, and which pages convert visitors into dashboard exploration. Review Core Web Vitals after every substantial frontend change.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
@ -715,11 +715,11 @@ export const SEO_CONTENT_PAGES: Record<SeoContentKey, SeoContentPage> = {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Saved search data is account-scoped',
|
title: 'Saved search data is account-scoped',
|
||||||
body: 'Saved searches and properties are intended for signed-in use. They are not included in the public sitemap and should not be crawlable as public content.',
|
body: 'Saved searches and properties are intended for signed-in use. They aren’t included in the public sitemap and shouldn’t be crawlable as public content.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Search measurement without exposing private data',
|
title: 'Search measurement without exposing private data',
|
||||||
body: 'SEO measurement should happen on public pages using aggregated analytics and Search Console data. Private query parameters and account views should not become indexable landing pages.',
|
body: 'SEO measurement should happen on public pages using aggregated analytics and Search Console data. Private query parameters and account views shouldn’t become indexable landing pages.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
faq: [
|
faq: [
|
||||||
|
|
@ -731,7 +731,7 @@ export const SEO_CONTENT_PAGES: Record<SeoContentKey, SeoContentPage> = {
|
||||||
{
|
{
|
||||||
question: 'Can private dashboard URLs appear in search?',
|
question: 'Can private dashboard URLs appear in search?',
|
||||||
answer:
|
answer:
|
||||||
'They should not be indexed. The server marks private routes noindex and the sitemap only lists public pages.',
|
'They shouldn’t be indexed. The server marks private routes noindex and the sitemap only lists public pages.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
relatedLinks: [
|
relatedLinks: [
|
||||||
|
|
|
||||||
|
|
@ -1,52 +1,57 @@
|
||||||
import type { Styles } from 'react-joyride';
|
import type { Options, Styles } from 'react-joyride';
|
||||||
|
|
||||||
export function getTutorialStyles(theme: 'light' | 'dark'): Partial<Styles> {
|
export function getTutorialStyles(theme: 'light' | 'dark'): {
|
||||||
|
options: Partial<Options>;
|
||||||
|
styles: Partial<Styles>;
|
||||||
|
} {
|
||||||
const isDark = theme === 'dark';
|
const isDark = theme === 'dark';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
options: {
|
options: {
|
||||||
arrowColor: isDark ? '#292524' : '#ffffff',
|
arrowColor: isDark ? '#292524' : '#ffffff',
|
||||||
backgroundColor: isDark ? '#292524' : '#ffffff',
|
backgroundColor: isDark ? '#292524' : '#ffffff',
|
||||||
|
disableFocusTrap: true,
|
||||||
|
hideOverlay: true,
|
||||||
overlayColor: isDark ? 'rgba(10,14,26,0.75)' : 'rgba(0,0,0,0.5)',
|
overlayColor: isDark ? 'rgba(10,14,26,0.75)' : 'rgba(0,0,0,0.5)',
|
||||||
primaryColor: '#00a28c',
|
primaryColor: '#00a28c',
|
||||||
|
spotlightRadius: 8,
|
||||||
textColor: isDark ? '#d6d3d1' : '#44403c',
|
textColor: isDark ? '#d6d3d1' : '#44403c',
|
||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
},
|
},
|
||||||
tooltip: {
|
styles: {
|
||||||
borderRadius: 8,
|
tooltip: {
|
||||||
padding: 16,
|
borderRadius: 8,
|
||||||
},
|
padding: 16,
|
||||||
tooltipTitle: {
|
},
|
||||||
color: isDark ? '#f5f5f4' : '#0a0e1a',
|
tooltipTitle: {
|
||||||
fontSize: 15,
|
color: isDark ? '#f5f5f4' : '#0a0e1a',
|
||||||
fontWeight: 600,
|
fontSize: 15,
|
||||||
},
|
fontWeight: 600,
|
||||||
tooltipContent: {
|
},
|
||||||
fontSize: 13,
|
tooltipContent: {
|
||||||
lineHeight: 1.5,
|
fontSize: 13,
|
||||||
padding: '8px 0 0',
|
lineHeight: 1.5,
|
||||||
},
|
padding: '8px 0 0',
|
||||||
buttonNext: {
|
},
|
||||||
borderRadius: 6,
|
buttonPrimary: {
|
||||||
fontSize: 13,
|
borderRadius: 6,
|
||||||
fontWeight: 500,
|
fontSize: 13,
|
||||||
padding: '6px 14px',
|
fontWeight: 500,
|
||||||
},
|
padding: '6px 14px',
|
||||||
buttonBack: {
|
},
|
||||||
color: isDark ? '#a8a29e' : '#78716c',
|
buttonBack: {
|
||||||
fontSize: 13,
|
color: isDark ? '#a8a29e' : '#78716c',
|
||||||
fontWeight: 500,
|
fontSize: 13,
|
||||||
marginRight: 8,
|
fontWeight: 500,
|
||||||
},
|
marginRight: 8,
|
||||||
buttonSkip: {
|
},
|
||||||
color: isDark ? '#78716c' : '#a8a29e',
|
buttonSkip: {
|
||||||
fontSize: 12,
|
color: isDark ? '#78716c' : '#a8a29e',
|
||||||
},
|
fontSize: 12,
|
||||||
buttonClose: {
|
},
|
||||||
color: isDark ? '#a8a29e' : '#78716c',
|
buttonClose: {
|
||||||
},
|
color: isDark ? '#a8a29e' : '#78716c',
|
||||||
spotlight: {
|
},
|
||||||
borderRadius: 8,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,15 @@ import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import type { FeatureMeta } from '../types';
|
import type { FeatureMeta } from '../types';
|
||||||
import { parseUrlState, stateToParams } from './url-state';
|
import { parseUrlState, stateToParams } from './url-state';
|
||||||
|
import { INITIAL_VIEW_STATE } from './consts';
|
||||||
import { createSchoolFilterKey } from './school-filter';
|
import { createSchoolFilterKey } from './school-filter';
|
||||||
import { createSpecificCrimeFilterKey } from './crime-filter';
|
import { createSpecificCrimeFilterKey } from './crime-filter';
|
||||||
|
import { createEthnicityFilterKey } from './ethnicity-filter';
|
||||||
|
import {
|
||||||
|
POI_COUNT_2KM_FILTER_NAME,
|
||||||
|
createPoiDistanceFilterKey,
|
||||||
|
createPoiFilterKey,
|
||||||
|
} from './poi-distance-filter';
|
||||||
|
|
||||||
describe('url-state', () => {
|
describe('url-state', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|
@ -43,6 +50,15 @@ describe('url-state', () => {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('leaves POIs unselected when URL params are omitted', () => {
|
||||||
|
const state = parseUrlState();
|
||||||
|
|
||||||
|
expect(state.viewState).toEqual(INITIAL_VIEW_STATE);
|
||||||
|
expect(state.filters).toEqual({});
|
||||||
|
expect(state.poiCategories).toEqual(new Set());
|
||||||
|
expect(state.tab).toBe('area');
|
||||||
|
});
|
||||||
|
|
||||||
it('serializes map state and active filters into stable URL params', () => {
|
it('serializes map state and active filters into stable URL params', () => {
|
||||||
const features: FeatureMeta[] = [
|
const features: FeatureMeta[] = [
|
||||||
{ name: 'Last known price', type: 'numeric' },
|
{ name: 'Last known price', type: 'numeric' },
|
||||||
|
|
@ -81,6 +97,17 @@ describe('url-state', () => {
|
||||||
expect(params.getAll('tt')).toEqual(['bicycle:bank:Bank:5:25']);
|
expect(params.getAll('tt')).toEqual(['bicycle:bank:Bank:5:25']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('round-trips an explicitly empty POI selection', () => {
|
||||||
|
const params = stateToParams(null, {}, [], new Set(), 'area');
|
||||||
|
|
||||||
|
expect(params.getAll('poi')).toEqual(['__none']);
|
||||||
|
|
||||||
|
window.history.replaceState({}, '', `/?${params.toString()}`);
|
||||||
|
const state = parseUrlState();
|
||||||
|
|
||||||
|
expect(state.poiCategories).toEqual(new Set());
|
||||||
|
});
|
||||||
|
|
||||||
it('round-trips repeated school filters with dedicated URL params', () => {
|
it('round-trips repeated school filters with dedicated URL params', () => {
|
||||||
const schoolOne = createSchoolFilterKey('primary', 'good', 2, 1);
|
const schoolOne = createSchoolFilterKey('primary', 'good', 2, 1);
|
||||||
const schoolTwo = createSchoolFilterKey('secondary', 'outstanding', 5, 2);
|
const schoolTwo = createSchoolFilterKey('secondary', 'outstanding', 5, 2);
|
||||||
|
|
@ -141,6 +168,91 @@ describe('url-state', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('round-trips repeated ethnicity filters with dedicated URL params', () => {
|
||||||
|
const white = createEthnicityFilterKey('% White', 3);
|
||||||
|
const southAsian = createEthnicityFilterKey('% South Asian', 4);
|
||||||
|
|
||||||
|
const params = stateToParams(
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
[white]: [10, 80],
|
||||||
|
[southAsian]: [5, 35],
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
new Set(),
|
||||||
|
'area'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(params.getAll('ethnicity')).toEqual(['% White:10:80', '% South Asian:5:35']);
|
||||||
|
expect(params.getAll('filter')).toEqual([]);
|
||||||
|
|
||||||
|
window.history.replaceState({}, '', `/?${params.toString()}`);
|
||||||
|
const state = parseUrlState();
|
||||||
|
|
||||||
|
expect(state.filters).toEqual({
|
||||||
|
[createEthnicityFilterKey('% White', 0)]: [10, 80],
|
||||||
|
[createEthnicityFilterKey('% South Asian', 1)]: [5, 35],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips repeated POI distance filters with dedicated URL params', () => {
|
||||||
|
const park = createPoiDistanceFilterKey('Distance to nearest park (km)', 3);
|
||||||
|
const tesco = createPoiDistanceFilterKey('Distance to nearest Tesco (km)', 4);
|
||||||
|
|
||||||
|
const params = stateToParams(
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
[park]: [0, 0.4],
|
||||||
|
[tesco]: [0, 1.5],
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
new Set(),
|
||||||
|
'area'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(params.getAll('poiDistance')).toEqual([
|
||||||
|
'Distance%20to%20nearest%20park%20(km):0:0.4',
|
||||||
|
'Distance%20to%20nearest%20Tesco%20(km):0:1.5',
|
||||||
|
]);
|
||||||
|
expect(params.getAll('filter')).toEqual([]);
|
||||||
|
|
||||||
|
window.history.replaceState({}, '', `/?${params.toString()}`);
|
||||||
|
const state = parseUrlState();
|
||||||
|
|
||||||
|
expect(state.filters).toEqual({
|
||||||
|
[createPoiDistanceFilterKey('Distance to nearest park (km)', 0)]: [0, 0.4],
|
||||||
|
[createPoiDistanceFilterKey('Distance to nearest Tesco (km)', 1)]: [0, 1.5],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips POI count filters with dedicated URL params', () => {
|
||||||
|
const cafes = createPoiFilterKey(
|
||||||
|
POI_COUNT_2KM_FILTER_NAME,
|
||||||
|
'Number of Cafe POIs within 2km',
|
||||||
|
3
|
||||||
|
);
|
||||||
|
|
||||||
|
const params = stateToParams(
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
[cafes]: [2, 8],
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
new Set(),
|
||||||
|
'area'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(params.getAll('poiCount2km')).toEqual(['Number%20of%20Cafe%20POIs%20within%202km:2:8']);
|
||||||
|
expect(params.getAll('filter')).toEqual([]);
|
||||||
|
|
||||||
|
window.history.replaceState({}, '', `/?${params.toString()}`);
|
||||||
|
const state = parseUrlState();
|
||||||
|
|
||||||
|
expect(state.filters).toEqual({
|
||||||
|
[createPoiFilterKey(POI_COUNT_2KM_FILTER_NAME, 'Number of Cafe POIs within 2km', 0)]: [2, 8],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('omits the default area tab', () => {
|
it('omits the default area tab', () => {
|
||||||
const params = stateToParams(null, {}, [], new Set(), 'area');
|
const params = stateToParams(null, {}, [], new Set(), 'area');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,7 @@
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["src/*"]
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"]
|
||||||
|
|
|
||||||
569
screenshot/package-lock.json
generated
569
screenshot/package-lock.json
generated
|
|
@ -8,13 +8,13 @@
|
||||||
"name": "screenshot",
|
"name": "screenshot",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.21.0",
|
"express": "^5.2.1",
|
||||||
"playwright": "^1.49.0"
|
"playwright": "^1.59.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.6",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^25.6.1",
|
||||||
"typescript": "^5.7.0"
|
"typescript": "^6.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/body-parser": {
|
"node_modules/@types/body-parser": {
|
||||||
|
|
@ -66,12 +66,13 @@
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.19.7",
|
"version": "25.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.1.tgz",
|
||||||
"integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==",
|
"integrity": "sha512-coJCN8O1q4AGyyqCAUSP06P+SrMTu18BkEj3NVAK07q6QUneD2wzj3CLv9+yP+BMeZQlMvneXqqvDe3w+xcq7g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~7.19.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/qs": {
|
"node_modules/@types/qs": {
|
||||||
|
|
@ -106,49 +107,47 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/accepts": {
|
"node_modules/accepts": {
|
||||||
"version": "1.3.8",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||||
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mime-types": "~2.1.34",
|
"mime-types": "^3.0.0",
|
||||||
"negotiator": "0.6.3"
|
"negotiator": "^1.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/array-flatten": {
|
|
||||||
"version": "1.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
|
||||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="
|
|
||||||
},
|
|
||||||
"node_modules/body-parser": {
|
"node_modules/body-parser": {
|
||||||
"version": "1.20.4",
|
"version": "2.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
|
||||||
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
|
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bytes": "~3.1.2",
|
"bytes": "^3.1.2",
|
||||||
"content-type": "~1.0.5",
|
"content-type": "^1.0.5",
|
||||||
"debug": "2.6.9",
|
"debug": "^4.4.3",
|
||||||
"depd": "2.0.0",
|
"http-errors": "^2.0.0",
|
||||||
"destroy": "~1.2.0",
|
"iconv-lite": "^0.7.0",
|
||||||
"http-errors": "~2.0.1",
|
"on-finished": "^2.4.1",
|
||||||
"iconv-lite": "~0.4.24",
|
"qs": "^6.14.1",
|
||||||
"on-finished": "~2.4.1",
|
"raw-body": "^3.0.1",
|
||||||
"qs": "~6.14.0",
|
"type-is": "^2.0.1"
|
||||||
"raw-body": "~2.5.3",
|
|
||||||
"type-is": "~1.6.18",
|
|
||||||
"unpipe": "~1.0.0"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8",
|
"node": ">=18"
|
||||||
"npm": "1.2.8000 || >= 1.4.16"
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/bytes": {
|
"node_modules/bytes": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
|
|
@ -157,6 +156,7 @@
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
"function-bind": "^1.1.2"
|
"function-bind": "^1.1.2"
|
||||||
|
|
@ -169,6 +169,7 @@
|
||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.2",
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
"get-intrinsic": "^1.3.0"
|
"get-intrinsic": "^1.3.0"
|
||||||
|
|
@ -181,20 +182,23 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/content-disposition": {
|
"node_modules/content-disposition": {
|
||||||
"version": "0.5.4",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
|
||||||
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
|
"integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
|
||||||
"dependencies": {
|
"license": "MIT",
|
||||||
"safe-buffer": "5.2.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/content-type": {
|
"node_modules/content-type": {
|
||||||
"version": "1.0.5",
|
"version": "1.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||||
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
|
|
@ -208,39 +212,45 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/cookie-signature": {
|
"node_modules/cookie-signature": {
|
||||||
"version": "1.0.7",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
||||||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="
|
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.6.0"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "2.6.9",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "2.0.0"
|
"ms": "^2.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/depd": {
|
"node_modules/depd": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/destroy": {
|
|
||||||
"version": "1.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
|
||||||
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8",
|
|
||||||
"npm": "1.2.8000 || >= 1.4.16"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.1",
|
"call-bind-apply-helpers": "^1.0.1",
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
|
|
@ -253,12 +263,14 @@
|
||||||
"node_modules/ee-first": {
|
"node_modules/ee-first": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="
|
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||||
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/encodeurl": {
|
"node_modules/encodeurl": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||||
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
|
|
@ -267,6 +279,7 @@
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
|
|
@ -275,6 +288,7 @@
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
|
|
@ -283,6 +297,7 @@
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0"
|
"es-errors": "^1.3.0"
|
||||||
},
|
},
|
||||||
|
|
@ -293,55 +308,55 @@
|
||||||
"node_modules/escape-html": {
|
"node_modules/escape-html": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="
|
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||||
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/etag": {
|
"node_modules/etag": {
|
||||||
"version": "1.8.1",
|
"version": "1.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||||
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
|
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/express": {
|
"node_modules/express": {
|
||||||
"version": "4.22.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||||
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "~1.3.8",
|
"accepts": "^2.0.0",
|
||||||
"array-flatten": "1.1.1",
|
"body-parser": "^2.2.1",
|
||||||
"body-parser": "~1.20.3",
|
"content-disposition": "^1.0.0",
|
||||||
"content-disposition": "~0.5.4",
|
"content-type": "^1.0.5",
|
||||||
"content-type": "~1.0.4",
|
"cookie": "^0.7.1",
|
||||||
"cookie": "~0.7.1",
|
"cookie-signature": "^1.2.1",
|
||||||
"cookie-signature": "~1.0.6",
|
"debug": "^4.4.0",
|
||||||
"debug": "2.6.9",
|
"depd": "^2.0.0",
|
||||||
"depd": "2.0.0",
|
"encodeurl": "^2.0.0",
|
||||||
"encodeurl": "~2.0.0",
|
"escape-html": "^1.0.3",
|
||||||
"escape-html": "~1.0.3",
|
"etag": "^1.8.1",
|
||||||
"etag": "~1.8.1",
|
"finalhandler": "^2.1.0",
|
||||||
"finalhandler": "~1.3.1",
|
"fresh": "^2.0.0",
|
||||||
"fresh": "~0.5.2",
|
"http-errors": "^2.0.0",
|
||||||
"http-errors": "~2.0.0",
|
"merge-descriptors": "^2.0.0",
|
||||||
"merge-descriptors": "1.0.3",
|
"mime-types": "^3.0.0",
|
||||||
"methods": "~1.1.2",
|
"on-finished": "^2.4.1",
|
||||||
"on-finished": "~2.4.1",
|
"once": "^1.4.0",
|
||||||
"parseurl": "~1.3.3",
|
"parseurl": "^1.3.3",
|
||||||
"path-to-regexp": "~0.1.12",
|
"proxy-addr": "^2.0.7",
|
||||||
"proxy-addr": "~2.0.7",
|
"qs": "^6.14.0",
|
||||||
"qs": "~6.14.0",
|
"range-parser": "^1.2.1",
|
||||||
"range-parser": "~1.2.1",
|
"router": "^2.2.0",
|
||||||
"safe-buffer": "5.2.1",
|
"send": "^1.1.0",
|
||||||
"send": "~0.19.0",
|
"serve-static": "^2.2.0",
|
||||||
"serve-static": "~1.16.2",
|
"statuses": "^2.0.1",
|
||||||
"setprototypeof": "1.2.0",
|
"type-is": "^2.0.1",
|
||||||
"statuses": "~2.0.1",
|
"vary": "^1.1.2"
|
||||||
"type-is": "~1.6.18",
|
|
||||||
"utils-merge": "1.0.1",
|
|
||||||
"vary": "~1.1.2"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.10.0"
|
"node": ">= 18"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
|
|
@ -349,20 +364,24 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/finalhandler": {
|
"node_modules/finalhandler": {
|
||||||
"version": "1.3.2",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
|
||||||
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
|
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"debug": "2.6.9",
|
"debug": "^4.4.0",
|
||||||
"encodeurl": "~2.0.0",
|
"encodeurl": "^2.0.0",
|
||||||
"escape-html": "~1.0.3",
|
"escape-html": "^1.0.3",
|
||||||
"on-finished": "~2.4.1",
|
"on-finished": "^2.4.1",
|
||||||
"parseurl": "~1.3.3",
|
"parseurl": "^1.3.3",
|
||||||
"statuses": "~2.0.2",
|
"statuses": "^2.0.1"
|
||||||
"unpipe": "~1.0.0"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8"
|
"node": ">= 18.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/forwarded": {
|
"node_modules/forwarded": {
|
||||||
|
|
@ -374,11 +393,12 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fresh": {
|
"node_modules/fresh": {
|
||||||
"version": "0.5.2",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
||||||
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
|
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
|
|
@ -398,6 +418,7 @@
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||||
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
|
|
@ -406,6 +427,7 @@
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.2",
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
"es-define-property": "^1.0.1",
|
"es-define-property": "^1.0.1",
|
||||||
|
|
@ -429,6 +451,7 @@
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dunder-proto": "^1.0.1",
|
"dunder-proto": "^1.0.1",
|
||||||
"es-object-atoms": "^1.0.0"
|
"es-object-atoms": "^1.0.0"
|
||||||
|
|
@ -441,6 +464,7 @@
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
},
|
},
|
||||||
|
|
@ -452,6 +476,7 @@
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
},
|
},
|
||||||
|
|
@ -460,9 +485,10 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/hasown": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
|
||||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"function-bind": "^1.1.2"
|
"function-bind": "^1.1.2"
|
||||||
},
|
},
|
||||||
|
|
@ -474,6 +500,7 @@
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||||
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"depd": "~2.0.0",
|
"depd": "~2.0.0",
|
||||||
"inherits": "~2.0.4",
|
"inherits": "~2.0.4",
|
||||||
|
|
@ -490,20 +517,26 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/iconv-lite": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.4.24",
|
"version": "0.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||||
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"safer-buffer": ">= 2.1.2 < 3"
|
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/inherits": {
|
"node_modules/inherits": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/ipaddr.js": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
|
|
@ -513,77 +546,78 @@
|
||||||
"node": ">= 0.10"
|
"node": ">= 0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-promise": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/media-typer": {
|
"node_modules/media-typer": {
|
||||||
"version": "0.3.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
|
||||||
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/merge-descriptors": {
|
"node_modules/merge-descriptors": {
|
||||||
"version": "1.0.3",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
|
||||||
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
|
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/methods": {
|
|
||||||
"version": "1.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
|
||||||
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mime": {
|
|
||||||
"version": "1.6.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
|
||||||
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
|
|
||||||
"bin": {
|
|
||||||
"mime": "cli.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mime-db": {
|
"node_modules/mime-db": {
|
||||||
"version": "1.52.0",
|
"version": "1.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
||||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mime-types": {
|
"node_modules/mime-types": {
|
||||||
"version": "2.1.35",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
|
||||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mime-db": "1.52.0"
|
"mime-db": "^1.54.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.0.0",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/negotiator": {
|
"node_modules/negotiator": {
|
||||||
"version": "0.6.3",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
||||||
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
|
|
@ -592,6 +626,7 @@
|
||||||
"version": "1.13.4",
|
"version": "1.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
},
|
},
|
||||||
|
|
@ -603,6 +638,7 @@
|
||||||
"version": "2.4.1",
|
"version": "2.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||||
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ee-first": "1.1.1"
|
"ee-first": "1.1.1"
|
||||||
},
|
},
|
||||||
|
|
@ -610,25 +646,41 @@
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/once": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"wrappy": "1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/parseurl": {
|
"node_modules/parseurl": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||||
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
|
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/path-to-regexp": {
|
"node_modules/path-to-regexp": {
|
||||||
"version": "0.1.12",
|
"version": "8.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
|
||||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="
|
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.58.1",
|
"version": "1.59.1",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.1.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
|
||||||
"integrity": "sha512-+2uTZHxSCcxjvGc5C891LrS1/NlxglGxzrC4seZiVjcYVQfUa87wBL6rTDqzGjuoWNjnBzRqKmF6zRYGMvQUaQ==",
|
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.58.1"
|
"playwright-core": "1.59.1"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
|
|
@ -641,9 +693,10 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright-core": {
|
"node_modules/playwright-core": {
|
||||||
"version": "1.58.1",
|
"version": "1.59.1",
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.1.tgz",
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
|
||||||
"integrity": "sha512-bcWzOaTxcW+VOOGBCQgnaKToLJ65d6AqfLVKEWvexyS3AS6rbXl+xdpYRMGSRBClPvyj44njOWoxjNdL/H9UNg==",
|
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright-core": "cli.js"
|
"playwright-core": "cli.js"
|
||||||
},
|
},
|
||||||
|
|
@ -664,9 +717,10 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.14.1",
|
"version": "6.15.1",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
|
||||||
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
|
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"side-channel": "^1.1.0"
|
"side-channel": "^1.1.0"
|
||||||
},
|
},
|
||||||
|
|
@ -681,99 +735,104 @@
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||||
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
|
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/raw-body": {
|
"node_modules/raw-body": {
|
||||||
"version": "2.5.3",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
|
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
|
||||||
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
|
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bytes": "~3.1.2",
|
"bytes": "~3.1.2",
|
||||||
"http-errors": "~2.0.1",
|
"http-errors": "~2.0.1",
|
||||||
"iconv-lite": "~0.4.24",
|
"iconv-lite": "~0.7.0",
|
||||||
"unpipe": "~1.0.0"
|
"unpipe": "~1.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/safe-buffer": {
|
"node_modules/router": {
|
||||||
"version": "5.2.1",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
||||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
|
||||||
"funding": [
|
"license": "MIT",
|
||||||
{
|
"dependencies": {
|
||||||
"type": "github",
|
"debug": "^4.4.0",
|
||||||
"url": "https://github.com/sponsors/feross"
|
"depd": "^2.0.0",
|
||||||
},
|
"is-promise": "^4.0.0",
|
||||||
{
|
"parseurl": "^1.3.3",
|
||||||
"type": "patreon",
|
"path-to-regexp": "^8.0.0"
|
||||||
"url": "https://www.patreon.com/feross"
|
},
|
||||||
},
|
"engines": {
|
||||||
{
|
"node": ">= 18"
|
||||||
"type": "consulting",
|
}
|
||||||
"url": "https://feross.org/support"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"node_modules/safer-buffer": {
|
"node_modules/safer-buffer": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/send": {
|
"node_modules/send": {
|
||||||
"version": "0.19.2",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
|
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
|
||||||
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
|
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"debug": "2.6.9",
|
"debug": "^4.4.3",
|
||||||
"depd": "2.0.0",
|
"encodeurl": "^2.0.0",
|
||||||
"destroy": "1.2.0",
|
"escape-html": "^1.0.3",
|
||||||
"encodeurl": "~2.0.0",
|
"etag": "^1.8.1",
|
||||||
"escape-html": "~1.0.3",
|
"fresh": "^2.0.0",
|
||||||
"etag": "~1.8.1",
|
"http-errors": "^2.0.1",
|
||||||
"fresh": "~0.5.2",
|
"mime-types": "^3.0.2",
|
||||||
"http-errors": "~2.0.1",
|
"ms": "^2.1.3",
|
||||||
"mime": "1.6.0",
|
"on-finished": "^2.4.1",
|
||||||
"ms": "2.1.3",
|
"range-parser": "^1.2.1",
|
||||||
"on-finished": "~2.4.1",
|
"statuses": "^2.0.2"
|
||||||
"range-parser": "~1.2.1",
|
|
||||||
"statuses": "~2.0.2"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8.0"
|
"node": ">= 18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/send/node_modules/ms": {
|
|
||||||
"version": "2.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
|
||||||
},
|
|
||||||
"node_modules/serve-static": {
|
"node_modules/serve-static": {
|
||||||
"version": "1.16.3",
|
"version": "2.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
|
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
|
||||||
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
|
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"encodeurl": "~2.0.0",
|
"encodeurl": "^2.0.0",
|
||||||
"escape-html": "~1.0.3",
|
"escape-html": "^1.0.3",
|
||||||
"parseurl": "~1.3.3",
|
"parseurl": "^1.3.3",
|
||||||
"send": "~0.19.1"
|
"send": "^1.2.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8.0"
|
"node": ">= 18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/setprototypeof": {
|
"node_modules/setprototypeof": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
|
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||||
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/side-channel": {
|
"node_modules/side-channel": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
"object-inspect": "^1.13.3",
|
"object-inspect": "^1.13.3",
|
||||||
|
|
@ -789,12 +848,13 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/side-channel-list": {
|
"node_modules/side-channel-list": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
||||||
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
"object-inspect": "^1.13.3"
|
"object-inspect": "^1.13.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
|
|
@ -807,6 +867,7 @@
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bound": "^1.0.2",
|
"call-bound": "^1.0.2",
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
|
|
@ -824,6 +885,7 @@
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bound": "^1.0.2",
|
"call-bound": "^1.0.2",
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
|
|
@ -842,6 +904,7 @@
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
|
|
@ -850,27 +913,31 @@
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||||
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.6"
|
"node": ">=0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/type-is": {
|
"node_modules/type-is": {
|
||||||
"version": "1.6.18",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
|
||||||
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"media-typer": "0.3.0",
|
"content-type": "^1.0.5",
|
||||||
"mime-types": "~2.1.24"
|
"media-typer": "^1.1.0",
|
||||||
|
"mime-types": "^3.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.9.3",
|
"version": "6.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
|
|
@ -880,27 +947,21 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "6.21.0",
|
"version": "7.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
|
||||||
"dev": true
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unpipe": {
|
"node_modules/unpipe": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||||
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/utils-merge": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vary": {
|
"node_modules/vary": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||||
|
|
@ -908,6 +969,12 @@
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrappy": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
|
"license": "ISC"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,12 @@
|
||||||
"dev": "tsc --watch & node --watch dist/server.js"
|
"dev": "tsc --watch & node --watch dist/server.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.21.0",
|
"express": "^5.2.1",
|
||||||
"playwright": "^1.49.0"
|
"playwright": "^1.59.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.6",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^25.6.1",
|
||||||
"typescript": "^5.7.0"
|
"typescript": "^6.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -292,8 +292,7 @@ export async function checkWebGL(): Promise<Record<string, unknown>> {
|
||||||
await page.setContent('<canvas id="c" width="1" height="1"></canvas>');
|
await page.setContent('<canvas id="c" width="1" height="1"></canvas>');
|
||||||
const info = await page.evaluate(() => {
|
const info = await page.evaluate(() => {
|
||||||
const canvas = document.getElementById('c') as HTMLCanvasElement;
|
const canvas = document.getElementById('c') as HTMLCanvasElement;
|
||||||
const gl =
|
const gl = canvas.getContext('webgl2');
|
||||||
canvas.getContext('webgl2') || canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
|
|
||||||
if (!gl) return { webgl: false, error: 'No WebGL context available' };
|
if (!gl) return { webgl: false, error: 'No WebGL context available' };
|
||||||
const g = gl as WebGLRenderingContext;
|
const g = gl as WebGLRenderingContext;
|
||||||
const debugExt = g.getExtension('WEBGL_debug_renderer_info');
|
const debugExt = g.getExtension('WEBGL_debug_renderer_info');
|
||||||
|
|
|
||||||
3160
server-rs/Cargo.lock
generated
3160
server-rs/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -9,8 +9,8 @@ clap = { version = "4", features = ["derive", "env"] }
|
||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
tower-http = { version = "0.6", features = ["cors", "fs", "compression-gzip", "compression-zstd", "trace"] }
|
tower-http = { version = "0.6", features = ["cors", "fs", "compression-gzip", "compression-zstd", "trace"] }
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
polars = { version = "0.46", features = ["parquet", "lazy", "dtype-struct", "dtype-u8", "dtype-u16", "dtype-i8", "dtype-i16", "round_series"] }
|
polars = { version = "0.53", features = ["parquet", "lazy", "dtype-struct", "dtype-u8", "dtype-u16", "dtype-i8", "dtype-i16", "round_series"] }
|
||||||
h3o = "0.7"
|
h3o = "0.9"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
parking_lot = "0.12"
|
parking_lot = "0.12"
|
||||||
|
|
@ -21,14 +21,14 @@ tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||||
tracing-appender = "0.2"
|
tracing-appender = "0.2"
|
||||||
metrics = "0.24"
|
metrics = "0.24"
|
||||||
metrics-exporter-prometheus = "0.16"
|
metrics-exporter-prometheus = "0.18"
|
||||||
reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream"] }
|
reqwest = { version = "0.13", features = ["rustls", "json", "stream", "form"] }
|
||||||
urlencoding = "2"
|
urlencoding = "2"
|
||||||
rust_xlsxwriter = "0.79"
|
rust_xlsxwriter = "0.94"
|
||||||
pmtiles = { version = "0.12", features = ["mmap-async-tokio"] }
|
pmtiles = { version = "0.23", features = ["mmap-async-tokio"] }
|
||||||
rand = "0.9"
|
rand = "0.10"
|
||||||
hmac = "0.12"
|
hmac = "0.13"
|
||||||
sha2 = "0.10"
|
sha2 = "0.11"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
tower = { version = "0.5", features = ["limit"] }
|
tower = { version = "0.5", features = ["limit"] }
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
|
|
||||||
26
video/package-lock.json
generated
26
video/package-lock.json
generated
|
|
@ -8,21 +8,21 @@
|
||||||
"name": "video",
|
"name": "video",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright": "^1.49.0"
|
"playwright": "^1.59.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^25.6.1",
|
||||||
"typescript": "^5.7.0"
|
"typescript": "^6.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.19.17",
|
"version": "25.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.1.tgz",
|
||||||
"integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==",
|
"integrity": "sha512-coJCN8O1q4AGyyqCAUSP06P+SrMTu18BkEj3NVAK07q6QUneD2wzj3CLv9+yP+BMeZQlMvneXqqvDe3w+xcq7g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~7.19.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
|
|
@ -70,9 +70,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.9.3",
|
"version": "6.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|
@ -84,9 +84,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "6.21.0",
|
"version": "7.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,10 @@
|
||||||
"render": "./render.sh"
|
"render": "./render.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright": "^1.49.0"
|
"playwright": "^1.59.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^25.6.1",
|
||||||
"typescript": "^5.7.0"
|
"typescript": "^6.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,326 +0,0 @@
|
||||||
import type { Page } from 'playwright';
|
|
||||||
import type { DashboardRecorder, HexagonClickTarget } from './dashboard.js';
|
|
||||||
import {
|
|
||||||
AI_ZOOM_SCALE,
|
|
||||||
BRAND_NAME,
|
|
||||||
BRAND_TAGLINE,
|
|
||||||
BRAND_URL,
|
|
||||||
PROMPT_TEXT,
|
|
||||||
TT_CARD_SELECTOR,
|
|
||||||
TT_DRAG_FROM_MIN,
|
|
||||||
TT_DRAG_TO_MIN,
|
|
||||||
TT_SLIDER_MAX,
|
|
||||||
} from './config.js';
|
|
||||||
import {
|
|
||||||
clearVignette,
|
|
||||||
flashRect,
|
|
||||||
hideCaption,
|
|
||||||
showCaption,
|
|
||||||
showOutro,
|
|
||||||
zoomReset,
|
|
||||||
zoomTo,
|
|
||||||
} from './dom.js';
|
|
||||||
import { fakeType, sleep, smoothMove, smoothDragSliderThumb } from './motion.js';
|
|
||||||
|
|
||||||
export interface SceneCtx {
|
|
||||||
page: Page;
|
|
||||||
dashboard: DashboardRecorder;
|
|
||||||
cursor: { x: number; y: number };
|
|
||||||
}
|
|
||||||
|
|
||||||
const AI_CLOSEUP_ZOOM_MS = 1400;
|
|
||||||
const RESULTS_ZOOM_OUT_MS = 1500;
|
|
||||||
const EXPORT_ZOOM_OUT_MS = 1100;
|
|
||||||
const PROMPT_TYPING_DELAY_MS = 64;
|
|
||||||
const MAP_ZOOM_WHEEL_STEPS = 18;
|
|
||||||
const MAP_ZOOM_WHEEL_DELTA = -120;
|
|
||||||
const MAP_ZOOM_WHEEL_PAUSE_MS = 70;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scene 1: start wide, then zoom into the AI prompt. The AI response is
|
|
||||||
* stubbed, while the map filters and right pane are loaded from the real app.
|
|
||||||
*/
|
|
||||||
export async function sceneAiCloseUp(ctx: SceneCtx): Promise<void> {
|
|
||||||
const { page, dashboard } = ctx;
|
|
||||||
|
|
||||||
await clearVignette(page);
|
|
||||||
await showCaption(page, 'Brief: flats or terraces under £450k near central Manchester.');
|
|
||||||
await sleep(180);
|
|
||||||
|
|
||||||
await zoomToAiBox(page, AI_CLOSEUP_ZOOM_MS);
|
|
||||||
await sleep(AI_CLOSEUP_ZOOM_MS + 160);
|
|
||||||
|
|
||||||
await fakeType(
|
|
||||||
page,
|
|
||||||
'[data-tutorial="ai-filters"] textarea',
|
|
||||||
PROMPT_TEXT,
|
|
||||||
PROMPT_TYPING_DELAY_MS
|
|
||||||
);
|
|
||||||
await sleep(160);
|
|
||||||
const aiResponse = page
|
|
||||||
.waitForResponse(
|
|
||||||
(response) => response.url().includes('/api/ai-filters') && response.status() === 200,
|
|
||||||
{ timeout: 1800 }
|
|
||||||
)
|
|
||||||
.catch(() => null);
|
|
||||||
const mapVersion = dashboard.getMapDataVersion();
|
|
||||||
await page.evaluate(() => {
|
|
||||||
document.querySelector<HTMLFormElement>('[data-tutorial="ai-filters"] form')?.requestSubmit();
|
|
||||||
});
|
|
||||||
await aiResponse;
|
|
||||||
await sleep(160);
|
|
||||||
await dashboard.waitForMapSettled(mapVersion, 15000);
|
|
||||||
await showCaption(page, 'The filters are already live on the map.');
|
|
||||||
await sleep(560);
|
|
||||||
await hideCaption(page);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scene 2: animate the wrapper back to scale 1 so the full dashboard is
|
|
||||||
* revealed. The map has already pan-flown to Manchester (MapPage's
|
|
||||||
* own flyTo fires when AI travel-time filters are applied), so the zoom-out
|
|
||||||
* lands on a useful, filtered view.
|
|
||||||
*/
|
|
||||||
export async function sceneZoomOutResults(ctx: SceneCtx): Promise<void> {
|
|
||||||
const { page } = ctx;
|
|
||||||
await showCaption(page, 'Now the view lands on central Manchester with the filters already set.');
|
|
||||||
await zoomReset(page, RESULTS_ZOOM_OUT_MS);
|
|
||||||
await sleep(RESULTS_ZOOM_OUT_MS + 160);
|
|
||||||
await hideCaption(page);
|
|
||||||
await sleep(180);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scene 3: drag the right thumb of the AI-applied travel-time slider from
|
|
||||||
* 35 to 20 minutes. The slider has step=1 over 0–120, so the drag is paced
|
|
||||||
* with real pointer updates instead of jumping the value directly.
|
|
||||||
*
|
|
||||||
* The card we drag (`tt_0`) only exists because the AI filter step inserted
|
|
||||||
* exactly one travel-time entry; if you change the AI stub's count, update
|
|
||||||
* the selector or this scene will time out.
|
|
||||||
*/
|
|
||||||
export async function sceneTravelTimeSlider(ctx: SceneCtx): Promise<void> {
|
|
||||||
const { page, dashboard } = ctx;
|
|
||||||
await showCaption(
|
|
||||||
page,
|
|
||||||
`Then tighten the commute: ${TT_DRAG_FROM_MIN} minutes down to ${TT_DRAG_TO_MIN}.`
|
|
||||||
);
|
|
||||||
|
|
||||||
const card = page.locator(TT_CARD_SELECTOR);
|
|
||||||
await card.waitFor({ state: 'visible', timeout: 4000 });
|
|
||||||
await card.scrollIntoViewIfNeeded();
|
|
||||||
await sleep(60);
|
|
||||||
|
|
||||||
// Two thumbs in a Radix range slider; the second one is the max.
|
|
||||||
const thumbSelector = `${TT_CARD_SELECTOR} [role="slider"] >> nth=1`;
|
|
||||||
// Track is the first horizontal-orientation element inside the card.
|
|
||||||
const trackSelector = `${TT_CARD_SELECTOR} [data-orientation="horizontal"] >> nth=0`;
|
|
||||||
|
|
||||||
// Slider goes 0..120, target = 20 → fraction 0.166...
|
|
||||||
const toFraction = TT_DRAG_TO_MIN / TT_SLIDER_MAX;
|
|
||||||
const mapVersion = dashboard.getMapDataVersion();
|
|
||||||
|
|
||||||
ctx.cursor = await smoothDragSliderThumb(
|
|
||||||
page,
|
|
||||||
thumbSelector,
|
|
||||||
trackSelector,
|
|
||||||
ctx.cursor,
|
|
||||||
toFraction,
|
|
||||||
1180
|
|
||||||
);
|
|
||||||
|
|
||||||
await sleep(220);
|
|
||||||
await dashboard.waitForMapSettled(mapVersion, 16000);
|
|
||||||
await showCaption(page, 'The map redraws around the areas that still work.');
|
|
||||||
await sleep(720);
|
|
||||||
await hideCaption(page);
|
|
||||||
await sleep(180);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scene 4: after the filtered result map is visible, zoom into Manchester,
|
|
||||||
* click a hexagon, then let the right pane open from that selection.
|
|
||||||
*/
|
|
||||||
export async function sceneClusterClick(ctx: SceneCtx): Promise<void> {
|
|
||||||
const { page } = ctx;
|
|
||||||
const viewport = page.viewportSize() ?? { width: 1920, height: 1080 };
|
|
||||||
|
|
||||||
await showCaption(page, 'Zoom into the result clusters, then click a promising hexagon.');
|
|
||||||
|
|
||||||
const defaultCluster = {
|
|
||||||
x: 360 + (viewport.width - 360) * 0.35,
|
|
||||||
y: viewport.height * 0.52,
|
|
||||||
};
|
|
||||||
const cluster = await pickMapZoomTarget(ctx, defaultCluster);
|
|
||||||
|
|
||||||
await smoothMove(page, ctx.cursor, cluster, { durationMs: 520 });
|
|
||||||
ctx.cursor = cluster;
|
|
||||||
await sleep(220);
|
|
||||||
|
|
||||||
await zoomMapWithWheel(ctx, cluster);
|
|
||||||
ctx.cursor = await clickVisibleHexagon(ctx, cluster);
|
|
||||||
await sleep(360);
|
|
||||||
await showCaption(
|
|
||||||
page,
|
|
||||||
'This is the useful pause: local stats, matching homes, and street context together.'
|
|
||||||
);
|
|
||||||
await sleep(1000);
|
|
||||||
await hideCaption(page);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pickMapZoomTarget(
|
|
||||||
ctx: SceneCtx,
|
|
||||||
fallback: { x: number; y: number }
|
|
||||||
): Promise<{ x: number; y: number }> {
|
|
||||||
const [target] = await ctx.dashboard.visibleHexagonTargets(1).catch(() => []);
|
|
||||||
return target ? { x: target.x, y: target.y } : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function zoomMapWithWheel(ctx: SceneCtx, target: { x: number; y: number }): Promise<void> {
|
|
||||||
const { page, dashboard } = ctx;
|
|
||||||
const mapVersion = dashboard.getMapDataVersion();
|
|
||||||
await page.mouse.move(target.x, target.y);
|
|
||||||
for (let i = 0; i < MAP_ZOOM_WHEEL_STEPS; i++) {
|
|
||||||
await page.mouse.wheel(0, MAP_ZOOM_WHEEL_DELTA);
|
|
||||||
await sleep(MAP_ZOOM_WHEEL_PAUSE_MS);
|
|
||||||
}
|
|
||||||
await dashboard.waitForMapSettled(mapVersion, 16000);
|
|
||||||
await sleep(260);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function clickVisibleHexagon(
|
|
||||||
ctx: SceneCtx,
|
|
||||||
fallbackTarget: { x: number; y: number }
|
|
||||||
): Promise<{ x: number; y: number }> {
|
|
||||||
const candidates = await ctx.dashboard.visibleHexagonTargets(8).catch((error) => {
|
|
||||||
console.log(
|
|
||||||
`[scene] Falling back to direct map click targets: ${
|
|
||||||
error instanceof Error ? error.message : String(error)
|
|
||||||
}`
|
|
||||||
);
|
|
||||||
return [];
|
|
||||||
});
|
|
||||||
const clickTargets = await addFallbackClickTargets(ctx, candidates, fallbackTarget);
|
|
||||||
const startedAt = ctx.dashboard.getSelectionStatsVersion();
|
|
||||||
let lastError: Error | null = null;
|
|
||||||
|
|
||||||
for (const target of clickTargets) {
|
|
||||||
await moveAndClickHexagon(ctx, target);
|
|
||||||
try {
|
|
||||||
await ctx.dashboard.waitForSelectionReady(startedAt, 7000);
|
|
||||||
return { x: target.x, y: target.y };
|
|
||||||
} catch (error) {
|
|
||||||
if (ctx.dashboard.getSelectionStatsVersion() > startedAt) {
|
|
||||||
return { x: target.x, y: target.y };
|
|
||||||
}
|
|
||||||
lastError = error instanceof Error ? error : new Error(String(error));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(
|
|
||||||
`Could not open a map selection from the visible hexagons${
|
|
||||||
lastError ? `: ${lastError.message}` : ''
|
|
||||||
}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addFallbackClickTargets(
|
|
||||||
ctx: SceneCtx,
|
|
||||||
candidates: HexagonClickTarget[],
|
|
||||||
fallbackTarget: { x: number; y: number }
|
|
||||||
): Promise<HexagonClickTarget[]> {
|
|
||||||
const mapBox = await ctx.page.locator('[data-tutorial="map"]').boundingBox();
|
|
||||||
const fallbacks: HexagonClickTarget[] = [
|
|
||||||
{
|
|
||||||
h3: 'direct-target',
|
|
||||||
x: fallbackTarget.x,
|
|
||||||
y: fallbackTarget.y,
|
|
||||||
count: 1,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (mapBox) {
|
|
||||||
fallbacks.push({
|
|
||||||
h3: 'map-center',
|
|
||||||
x: mapBox.x + mapBox.width / 2,
|
|
||||||
y: mapBox.y + mapBox.height / 2,
|
|
||||||
count: 1,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const seen = new Set<string>();
|
|
||||||
return [...candidates, ...fallbacks].filter((target) => {
|
|
||||||
const key = `${Math.round(target.x / 12)},${Math.round(target.y / 12)}`;
|
|
||||||
if (seen.has(key)) return false;
|
|
||||||
seen.add(key);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function moveAndClickHexagon(ctx: SceneCtx, target: HexagonClickTarget): Promise<void> {
|
|
||||||
await smoothMove(ctx.page, ctx.cursor, { x: target.x, y: target.y }, { durationMs: 420 });
|
|
||||||
ctx.cursor = { x: target.x, y: target.y };
|
|
||||||
await ctx.page.mouse.click(target.x, target.y);
|
|
||||||
await sleep(140);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Export the current shortlist, then reveal the URL. */
|
|
||||||
export async function sceneExportAndOutro(ctx: SceneCtx): Promise<void> {
|
|
||||||
const { page } = ctx;
|
|
||||||
|
|
||||||
await showCaption(page, 'When the shortlist feels right, export the exact filtered view.');
|
|
||||||
await zoomReset(page, EXPORT_ZOOM_OUT_MS);
|
|
||||||
await sleep(EXPORT_ZOOM_OUT_MS + 120);
|
|
||||||
|
|
||||||
const exportButton = page.locator('button[title="Export to Excel"]').first();
|
|
||||||
await exportButton.waitFor({ state: 'visible', timeout: 4000 });
|
|
||||||
const box = await exportButton.boundingBox();
|
|
||||||
if (!box) throw new Error('Export button has no bounding box');
|
|
||||||
const target = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
|
||||||
|
|
||||||
await smoothMove(page, ctx.cursor, target, { durationMs: 620 });
|
|
||||||
ctx.cursor = target;
|
|
||||||
await sleep(160);
|
|
||||||
|
|
||||||
const download = page.waitForEvent('download', { timeout: 4000 }).catch(() => null);
|
|
||||||
await page.mouse.click(target.x, target.y);
|
|
||||||
await flashRect(page, box);
|
|
||||||
|
|
||||||
await sleep(680);
|
|
||||||
await hideCaption(page);
|
|
||||||
await showOutro(ctx.page, BRAND_NAME, BRAND_TAGLINE, BRAND_URL);
|
|
||||||
void download;
|
|
||||||
await sleep(2200);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Open the AI prompt before the timed scene starts. */
|
|
||||||
export async function prepareAiBox(ctx: SceneCtx): Promise<void> {
|
|
||||||
const { page } = ctx;
|
|
||||||
|
|
||||||
const aiRoot = page.locator('[data-tutorial="ai-filters"]').first();
|
|
||||||
await aiRoot.waitFor({ state: 'visible', timeout: 15000 });
|
|
||||||
|
|
||||||
const textarea = page.locator('[data-tutorial="ai-filters"] textarea');
|
|
||||||
if (!(await textarea.isVisible().catch(() => false))) {
|
|
||||||
const aiButton = aiRoot.locator('button').first();
|
|
||||||
await aiButton.waitFor({ state: 'visible', timeout: 8000 });
|
|
||||||
const btnBox = await aiButton.boundingBox();
|
|
||||||
if (btnBox) await page.mouse.click(btnBox.x + btnBox.width / 2, btnBox.y + btnBox.height / 2);
|
|
||||||
}
|
|
||||||
if (!(await textarea.isVisible().catch(() => false))) {
|
|
||||||
await page.evaluate(() => {
|
|
||||||
document.querySelector<HTMLElement>('[data-tutorial="ai-filters"] button')?.click();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await textarea.waitFor({ state: 'visible', timeout: 15000 });
|
|
||||||
await sleep(100);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function zoomToAiBox(page: Page, durationMs: number): Promise<void> {
|
|
||||||
const aiCard = page.locator('[data-tutorial="ai-filters"]');
|
|
||||||
const cardBox = await aiCard.boundingBox();
|
|
||||||
if (!cardBox) throw new Error('AI card has no bounding box');
|
|
||||||
const focusX = cardBox.x + cardBox.width / 2;
|
|
||||||
const focusY = cardBox.y + cardBox.height / 2;
|
|
||||||
await zoomTo(page, { scale: AI_ZOOM_SCALE, focusX, focusY, durationMs });
|
|
||||||
}
|
|
||||||
29
video/tts/pyproject.toml
Normal file
29
video/tts/pyproject.toml
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
[project]
|
||||||
|
name = "property-map-video-tts"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Qwen3-TTS narration generator for the homepage demo video."
|
||||||
|
requires-python = ">=3.12,<3.13"
|
||||||
|
dependencies = [
|
||||||
|
"qwen-tts>=0.1.1",
|
||||||
|
# Host driver is CUDA 12.4 (see `nvidia-smi`). torch 2.7+ dropped cu124
|
||||||
|
# wheels, so we cap below that and pull the cu124 build from PyTorch's
|
||||||
|
# own index (configured below). torchaudio must match torch's CUDA build
|
||||||
|
# — the PyPI default ships a CUDA 13 binary that fails to load
|
||||||
|
# libcudart.so.13 on this host.
|
||||||
|
"torch>=2.5,<2.7",
|
||||||
|
"torchaudio>=2.5,<2.7",
|
||||||
|
"soundfile>=0.12",
|
||||||
|
"numpy>=1.26",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.uv]
|
||||||
|
environments = ["sys_platform == 'linux' and python_version < '3.13'"]
|
||||||
|
|
||||||
|
[tool.uv.sources]
|
||||||
|
torch = [{ index = "pytorch-cu124" }]
|
||||||
|
torchaudio = [{ index = "pytorch-cu124" }]
|
||||||
|
|
||||||
|
[[tool.uv.index]]
|
||||||
|
name = "pytorch-cu124"
|
||||||
|
url = "https://download.pytorch.org/whl/cu124"
|
||||||
|
explicit = true
|
||||||
1278
video/tts/uv.lock
generated
Normal file
1278
video/tts/uv.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue