Polish homepage trend interactions
This commit is contained in:
@@ -1314,6 +1314,49 @@
|
|||||||
stroke-width: 1.6;
|
stroke-width: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.root :global(.home-intraday-hotspot) {
|
||||||
|
position: absolute;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.home-intraday-hotspot:focus-visible) {
|
||||||
|
outline: 1px solid rgba(34, 211, 238, 0.6);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.home-intraday-tooltip) {
|
||||||
|
position: absolute;
|
||||||
|
transform: translate(-50%, -100%);
|
||||||
|
pointer-events: none;
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid rgba(34, 211, 238, 0.22);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(7, 15, 28, 0.96);
|
||||||
|
box-shadow: 0 10px 26px rgba(2, 6, 23, 0.34);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.home-intraday-tooltip strong) {
|
||||||
|
color: #f8fafc;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.home-intraday-tooltip span) {
|
||||||
|
color: rgba(148, 163, 184, 0.88);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.root :global(.home-intraday-meta) {
|
.root :global(.home-intraday-meta) {
|
||||||
color: rgba(148, 163, 184, 0.9);
|
color: rgba(148, 163, 184, 0.9);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useMemo, useRef } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import styles from "./Dashboard.module.css";
|
import styles from "./Dashboard.module.css";
|
||||||
import detailChromeStyles from "./DetailPanelChrome.module.css";
|
import detailChromeStyles from "./DetailPanelChrome.module.css";
|
||||||
import modalChromeStyles from "./ModalChrome.module.css";
|
import modalChromeStyles from "./ModalChrome.module.css";
|
||||||
@@ -222,6 +222,13 @@ type HomeTrendChart = {
|
|||||||
forecastPath: string;
|
forecastPath: string;
|
||||||
legendText: string;
|
legendText: string;
|
||||||
observationDots: Array<{ cx: number; cy: number; key: string }>;
|
observationDots: Array<{ cx: number; cy: number; key: string }>;
|
||||||
|
hoverPoints: Array<{
|
||||||
|
cx: number;
|
||||||
|
cy: number;
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
temperatureText: string;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type HomeForecastDay = {
|
type HomeForecastDay = {
|
||||||
@@ -230,6 +237,8 @@ type HomeForecastDay = {
|
|||||||
maxTemp: number;
|
maxTemp: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const RECENT_OPENED_CITIES_STORAGE_KEY = "polyWeather_recent_opened_cities_v1";
|
||||||
|
|
||||||
function projectHomeTrendPoint(
|
function projectHomeTrendPoint(
|
||||||
x: number,
|
x: number,
|
||||||
y: number,
|
y: number,
|
||||||
@@ -319,11 +328,30 @@ function buildHomeTrendChart(
|
|||||||
key: `${point.labelTime}-${index}`,
|
key: `${point.labelTime}-${index}`,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
const hoverSource = observationSeries.length > 0 ? observationSeries : forecastSeries;
|
||||||
|
const hoverPoints = hoverSource.map((point, index) => {
|
||||||
|
const projected = projectHomeTrendPoint(
|
||||||
|
point.x,
|
||||||
|
point.y,
|
||||||
|
chartData.xMin,
|
||||||
|
chartData.xMax,
|
||||||
|
chartData.min,
|
||||||
|
chartData.max,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
cx: projected.cx,
|
||||||
|
cy: projected.cy,
|
||||||
|
key: `hover-${point.labelTime}-${index}`,
|
||||||
|
label: point.labelTime,
|
||||||
|
temperatureText: formatTemperature(point.y, detail.temp_symbol || "°C"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
forecastPath,
|
forecastPath,
|
||||||
legendText: chartData.legendText,
|
legendText: chartData.legendText,
|
||||||
observationDots,
|
observationDots,
|
||||||
|
hoverPoints,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -441,6 +469,13 @@ function isTradableMarketOpportunity(detail?: CityDetail | null) {
|
|||||||
function HomeIntelligencePanel({ snapshots }: { snapshots: CitySnapshot[] }) {
|
function HomeIntelligencePanel({ snapshots }: { snapshots: CitySnapshot[] }) {
|
||||||
const store = useDashboardStore();
|
const store = useDashboardStore();
|
||||||
const { locale } = useI18n();
|
const { locale } = useI18n();
|
||||||
|
const [hoveredTrendPoint, setHoveredTrendPoint] = useState<{
|
||||||
|
cx: number;
|
||||||
|
cy: number;
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
temperatureText: string;
|
||||||
|
} | null>(null);
|
||||||
const selectedSnapshot = store.selectedCity
|
const selectedSnapshot = store.selectedCity
|
||||||
? snapshots.find((snapshot) => snapshot.city.name === store.selectedCity)
|
? snapshots.find((snapshot) => snapshot.city.name === store.selectedCity)
|
||||||
: null;
|
: null;
|
||||||
@@ -672,6 +707,39 @@ function HomeIntelligencePanel({ snapshots }: { snapshots: CitySnapshot[] }) {
|
|||||||
<circle key={point.key} cx={point.cx} cy={point.cy} r="3.2" />
|
<circle key={point.key} cx={point.cx} cy={point.cy} r="3.2" />
|
||||||
))}
|
))}
|
||||||
</svg>
|
</svg>
|
||||||
|
{trendChart.hoverPoints.map((point) => (
|
||||||
|
<button
|
||||||
|
key={point.key}
|
||||||
|
type="button"
|
||||||
|
className="home-intraday-hotspot"
|
||||||
|
style={{ left: `${point.cx}px`, top: `${point.cy}px` }}
|
||||||
|
aria-label={`${point.label} ${point.temperatureText}`}
|
||||||
|
onMouseEnter={() => setHoveredTrendPoint(point)}
|
||||||
|
onMouseLeave={() =>
|
||||||
|
setHoveredTrendPoint((current) =>
|
||||||
|
current?.key === point.key ? null : current,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onFocus={() => setHoveredTrendPoint(point)}
|
||||||
|
onBlur={() =>
|
||||||
|
setHoveredTrendPoint((current) =>
|
||||||
|
current?.key === point.key ? null : current,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{hoveredTrendPoint ? (
|
||||||
|
<div
|
||||||
|
className="home-intraday-tooltip"
|
||||||
|
style={{
|
||||||
|
left: `${Math.min(248, Math.max(48, hoveredTrendPoint.cx))}px`,
|
||||||
|
top: `${Math.max(8, hoveredTrendPoint.cy - 34)}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>{hoveredTrendPoint.temperatureText}</strong>
|
||||||
|
<span>{hoveredTrendPoint.label}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="home-intraday-meta">
|
<div className="home-intraday-meta">
|
||||||
{trendChart.legendText ||
|
{trendChart.legendText ||
|
||||||
@@ -825,10 +893,21 @@ function HomeIntelligencePanel({ snapshots }: { snapshots: CitySnapshot[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function OpportunityStrip({ snapshots }: { snapshots: CitySnapshot[] }) {
|
function OpportunityStrip({
|
||||||
|
snapshots,
|
||||||
|
recentCityNames,
|
||||||
|
}: {
|
||||||
|
snapshots: CitySnapshot[];
|
||||||
|
recentCityNames: string[];
|
||||||
|
}) {
|
||||||
const store = useDashboardStore();
|
const store = useDashboardStore();
|
||||||
const { locale } = useI18n();
|
const { locale } = useI18n();
|
||||||
const items = snapshots.slice(0, 4);
|
const items = recentCityNames
|
||||||
|
.map((cityName) =>
|
||||||
|
snapshots.find((snapshot) => snapshot.city.name === cityName) || null,
|
||||||
|
)
|
||||||
|
.filter((snapshot): snapshot is CitySnapshot => snapshot != null)
|
||||||
|
.slice(0, 4);
|
||||||
|
|
||||||
if (!items.length) return null;
|
if (!items.length) return null;
|
||||||
|
|
||||||
@@ -838,9 +917,9 @@ function OpportunityStrip({ snapshots }: { snapshots: CitySnapshot[] }) {
|
|||||||
aria-label={locale === "en-US" ? "Opportunity strip" : "机会条"}
|
aria-label={locale === "en-US" ? "Opportunity strip" : "机会条"}
|
||||||
>
|
>
|
||||||
<div className="opportunity-strip-heading">
|
<div className="opportunity-strip-heading">
|
||||||
<span>{locale === "en-US" ? "Today focus" : "今日焦点"}</span>
|
<span>{locale === "en-US" ? "Recently opened" : "最近打开"}</span>
|
||||||
<strong>
|
<strong>
|
||||||
{locale === "en-US" ? "Cities worth opening first" : "优先打开的城市"}
|
{locale === "en-US" ? "Recently opened cities" : "最近打开的城市"}
|
||||||
</strong>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="opportunity-card-grid">
|
<div className="opportunity-card-grid">
|
||||||
@@ -884,6 +963,7 @@ function DashboardScreen() {
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const didAutoFocusRef = useRef(false);
|
const didAutoFocusRef = useRef(false);
|
||||||
const preloadedOpportunityRef = useRef<Set<string>>(new Set());
|
const preloadedOpportunityRef = useRef<Set<string>>(new Set());
|
||||||
|
const [recentCityNames, setRecentCityNames] = useState<string[]>([]);
|
||||||
const activeSummary = store.selectedCity
|
const activeSummary = store.selectedCity
|
||||||
? store.citySummariesByName[store.selectedCity] || null
|
? store.citySummariesByName[store.selectedCity] || null
|
||||||
: null;
|
: null;
|
||||||
@@ -945,6 +1025,19 @@ function DashboardScreen() {
|
|||||||
const showHomepageChrome =
|
const showHomepageChrome =
|
||||||
!store.historyState.isOpen && !store.futureModalDate;
|
!store.historyState.isOpen && !store.futureModalDate;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(RECENT_OPENED_CITIES_STORAGE_KEY);
|
||||||
|
if (!raw) return;
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) return;
|
||||||
|
setRecentCityNames(
|
||||||
|
parsed.map((item) => String(item || "").trim()).filter(Boolean).slice(0, 8),
|
||||||
|
);
|
||||||
|
} catch {}
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (didAutoFocusRef.current) return;
|
if (didAutoFocusRef.current) return;
|
||||||
if (!showHomepageChrome) return;
|
if (!showHomepageChrome) return;
|
||||||
@@ -968,6 +1061,26 @@ function DashboardScreen() {
|
|||||||
});
|
});
|
||||||
}, [homepageSnapshots, showHomepageChrome, store]);
|
}, [homepageSnapshots, showHomepageChrome, store]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const selectedCity = store.selectedCity;
|
||||||
|
if (!selectedCity) return;
|
||||||
|
setRecentCityNames((current) => {
|
||||||
|
const next = [
|
||||||
|
selectedCity,
|
||||||
|
...current.filter((name) => name !== selectedCity),
|
||||||
|
].slice(0, 8);
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(
|
||||||
|
RECENT_OPENED_CITIES_STORAGE_KEY,
|
||||||
|
JSON.stringify(next),
|
||||||
|
);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [store.selectedCity]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@@ -982,7 +1095,14 @@ function DashboardScreen() {
|
|||||||
{showHomepageChrome ? (
|
{showHomepageChrome ? (
|
||||||
<>
|
<>
|
||||||
<HomeIntelligencePanel snapshots={homepageSnapshots} />
|
<HomeIntelligencePanel snapshots={homepageSnapshots} />
|
||||||
<OpportunityStrip snapshots={homepageSnapshots} />
|
<OpportunityStrip
|
||||||
|
snapshots={homepageSnapshots}
|
||||||
|
recentCityNames={
|
||||||
|
recentCityNames.length
|
||||||
|
? recentCityNames
|
||||||
|
: homepageSnapshots.slice(0, 4).map((snapshot) => snapshot.city.name)
|
||||||
|
}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{showCitySyncToast ? (
|
{showCitySyncToast ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user