b5a6b1b68d
CI/CD / CI · API (dotnet build + test) (pull_request) Successful in 46s
CI/CD / CI · Admin API (dotnet build) (pull_request) Successful in 31s
CI/CD / CI · Dashboard (tsc) (pull_request) Successful in 1m5s
CI/CD / CI · Admin Web (tsc) (pull_request) Successful in 35s
CI/CD / CI · Website (tsc) (pull_request) Successful in 45s
CI/CD / CI · Koja (tsc) (pull_request) Successful in 51s
CI/CD / Deploy · all services (pull_request) Has been skipped
Replaced the rough 40-point hand-drawn polygon with the real national border (74 vertices, Natural Earth via world.geo.json) and fitted the projection bounding box to Iran's true extent, so the silhouette is recognisable and café markers stay aligned. Reworked the marker animation from a radar-style expanding ring into a slow 3.6s ease-in-out lamp fade (opacity 1->0.2->1) with a halo that glows on and off in sync. Verified via the SVG timeline: opacity 1.0 at 0s, 0.2 at 1.8s, 1.0 at 3.6s. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
261 lines
10 KiB
TypeScript
261 lines
10 KiB
TypeScript
/**
|
||
* IranMapSection — server component
|
||
* Fetches real café locations from the API and overlays them as blinking dots
|
||
* on a stylised SVG silhouette of Iran.
|
||
*/
|
||
import { Suspense } from "react";
|
||
|
||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||
|
||
type MapMarker = {
|
||
id: string;
|
||
name: string;
|
||
city: string | null;
|
||
latitude: number;
|
||
longitude: number;
|
||
};
|
||
|
||
type MarkersApiResponse = {
|
||
success: boolean;
|
||
data: MapMarker[];
|
||
};
|
||
|
||
// ── Coordinate transform ──────────────────────────────────────────────────────
|
||
|
||
// Iran bounding box (degrees) — fitted to the real border extent
|
||
// (lng 44.11–63.32, lat 25.08–39.71) with a small margin so the
|
||
// silhouette fills the viewBox. Markers reproject with the same box,
|
||
// so they stay aligned with the outline.
|
||
const MIN_LNG = 43.6;
|
||
const MAX_LNG = 63.8;
|
||
const MIN_LAT = 24.6;
|
||
const MAX_LAT = 40.2;
|
||
const SVG_W = 600;
|
||
const SVG_H = 500;
|
||
|
||
const toX = (lng: number) =>
|
||
((lng - MIN_LNG) / (MAX_LNG - MIN_LNG)) * SVG_W;
|
||
|
||
const toY = (lat: number) =>
|
||
((MAX_LAT - lat) / (MAX_LAT - MIN_LAT)) * SVG_H;
|
||
|
||
function toPt([lng, lat]: [number, number]) {
|
||
return `${toX(lng).toFixed(1)},${toY(lat).toFixed(1)}`;
|
||
}
|
||
|
||
// ── Iran silhouette ────────────────────────────────────────────────────────────
|
||
// Real national border, simplified to 74 vertices (source: Natural Earth via
|
||
// world.geo.json). Coordinates are [longitude, latitude]; the ring starts on
|
||
// the Caspian (NE) and runs clockwise. Projected through toX/toY below, the
|
||
// same transform used for the café markers, so dots land in the right place.
|
||
const IRAN_OUTLINE: [number, number][] = [
|
||
[53.92, 37.20], [54.80, 37.39], [55.51, 37.96], [56.18, 37.94], [56.62, 38.12], [57.33, 38.03],
|
||
[58.44, 37.52], [59.23, 37.41], [60.38, 36.53], [61.12, 36.49], [61.21, 35.65], [60.80, 34.40],
|
||
[60.53, 33.68], [60.96, 33.53], [60.54, 32.98], [60.86, 32.18], [60.94, 31.55], [61.70, 31.38],
|
||
[61.78, 30.74], [60.87, 29.83], [61.37, 29.30], [61.77, 28.70], [62.73, 28.26], [62.76, 27.38],
|
||
[63.23, 27.22], [63.32, 26.76], [61.87, 26.24], [61.50, 25.08], [59.62, 25.38], [58.53, 25.61],
|
||
[57.40, 25.74], [56.97, 26.97], [56.49, 27.14], [55.72, 26.96], [54.72, 26.48], [53.49, 26.81],
|
||
[52.48, 27.58], [51.52, 27.87], [50.85, 28.81], [50.12, 30.15], [49.58, 29.99], [48.94, 30.32],
|
||
[48.57, 29.93], [48.01, 30.45], [48.00, 30.99], [47.69, 30.98], [47.85, 31.71], [47.33, 32.47],
|
||
[46.11, 33.02], [45.42, 33.97], [45.65, 34.75], [46.15, 35.09], [46.08, 35.68], [45.42, 35.98],
|
||
[44.77, 37.17], [44.23, 37.97], [44.42, 38.28], [44.11, 39.43], [44.79, 39.71], [44.95, 39.34],
|
||
[45.46, 38.87], [46.14, 38.74], [46.51, 38.77], [47.69, 39.51], [48.06, 39.58], [48.36, 39.29],
|
||
[48.01, 38.79], [48.63, 38.27], [48.88, 38.32], [49.20, 37.58], [50.15, 37.37], [50.84, 36.87],
|
||
[52.26, 36.70], [53.83, 36.97],
|
||
];
|
||
|
||
const IRAN_PATH =
|
||
"M " +
|
||
IRAN_OUTLINE.map(toPt).join(" L ") +
|
||
" Z";
|
||
|
||
// A handful of major cities shown as faint reference dots
|
||
const MAJOR_CITIES: { name: string; lng: number; lat: number }[] = [
|
||
{ name: "تهران", lng: 51.389, lat: 35.689 },
|
||
{ name: "مشهد", lng: 59.608, lat: 36.297 },
|
||
{ name: "اصفهان", lng: 51.668, lat: 32.661 },
|
||
{ name: "شیراز", lng: 52.531, lat: 29.594 },
|
||
{ name: "تبریز", lng: 46.291, lat: 38.08 },
|
||
{ name: "اهواز", lng: 48.683, lat: 31.318 },
|
||
];
|
||
|
||
// ── Data fetcher ──────────────────────────────────────────────────────────────
|
||
|
||
async function fetchMarkers(): Promise<MapMarker[]> {
|
||
try {
|
||
const apiBase =
|
||
process.env.MEEZI_API_URL ?? "https://api.meezi.ir";
|
||
const res = await fetch(`${apiBase}/api/public/map-markers`, {
|
||
next: { revalidate: 3600 },
|
||
});
|
||
if (!res.ok) return [];
|
||
const json = (await res.json()) as MarkersApiResponse;
|
||
return json.data ?? [];
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
// ── Sub-components ────────────────────────────────────────────────────────────
|
||
|
||
async function IranMapSvg() {
|
||
const markers = await fetchMarkers();
|
||
|
||
return (
|
||
<div className="relative mx-auto w-full max-w-lg select-none">
|
||
<svg
|
||
viewBox={`0 0 ${SVG_W} ${SVG_H}`}
|
||
aria-label="نقشه ایران با موقعیت کافهها"
|
||
className="w-full drop-shadow-lg"
|
||
>
|
||
{/* Glow filter */}
|
||
<defs>
|
||
<filter id="glow">
|
||
<feGaussianBlur stdDeviation="3" result="blur" />
|
||
<feMerge>
|
||
<feMergeNode in="blur" />
|
||
<feMergeNode in="SourceGraphic" />
|
||
</feMerge>
|
||
</filter>
|
||
<radialGradient id="mapGrad" cx="50%" cy="50%" r="60%">
|
||
<stop offset="0%" stopColor="#e8f5f1" />
|
||
<stop offset="100%" stopColor="#d1ece5" />
|
||
</radialGradient>
|
||
</defs>
|
||
|
||
{/* Iran silhouette */}
|
||
<path
|
||
d={IRAN_PATH}
|
||
fill="url(#mapGrad)"
|
||
stroke="#0F6E56"
|
||
strokeWidth="2"
|
||
strokeLinejoin="round"
|
||
opacity="0.9"
|
||
/>
|
||
|
||
{/* Major city reference dots (faint) */}
|
||
{MAJOR_CITIES.map((city) => (
|
||
<g key={city.name}>
|
||
<circle
|
||
cx={toX(city.lng)}
|
||
cy={toY(city.lat)}
|
||
r={3}
|
||
fill="#0F6E56"
|
||
opacity={0.25}
|
||
/>
|
||
</g>
|
||
))}
|
||
|
||
{/* Café markers — each glows slowly on and off like a small lamp.
|
||
Halo and core brighten/dim together (ease-in-out), staggered so the
|
||
map twinkles organically rather than pulsing in unison. */}
|
||
{markers.map((m, idx) => {
|
||
const cx = toX(m.longitude);
|
||
const cy = toY(m.latitude);
|
||
const delay = `${((idx * 0.7) % 3.6).toFixed(2)}s`;
|
||
const dur = "3.6s";
|
||
// ease-in-out for a smooth lamp-like fade
|
||
const ease = "0.4 0 0.6 1; 0.4 0 0.6 1";
|
||
return (
|
||
<g key={m.id} filter="url(#glow)">
|
||
{/* Soft halo */}
|
||
<circle cx={cx} cy={cy} r={9} fill="#0F6E56">
|
||
<animate
|
||
attributeName="opacity"
|
||
values="0.45;0.04;0.45"
|
||
keyTimes="0;0.5;1"
|
||
calcMode="spline"
|
||
keySplines={ease}
|
||
dur={dur}
|
||
begin={delay}
|
||
repeatCount="indefinite"
|
||
/>
|
||
</circle>
|
||
{/* Core dot — turns on (bright, slightly larger) and off (dim) */}
|
||
<circle cx={cx} cy={cy} r={4.5} fill="#0F6E56">
|
||
<animate
|
||
attributeName="opacity"
|
||
values="1;0.2;1"
|
||
keyTimes="0;0.5;1"
|
||
calcMode="spline"
|
||
keySplines={ease}
|
||
dur={dur}
|
||
begin={delay}
|
||
repeatCount="indefinite"
|
||
/>
|
||
<animate
|
||
attributeName="r"
|
||
values="4.5;5.6;4.5"
|
||
keyTimes="0;0.5;1"
|
||
calcMode="spline"
|
||
keySplines={ease}
|
||
dur={dur}
|
||
begin={delay}
|
||
repeatCount="indefinite"
|
||
/>
|
||
</circle>
|
||
</g>
|
||
);
|
||
})}
|
||
</svg>
|
||
|
||
{/* Floating legend */}
|
||
{markers.length > 0 && (
|
||
<div className="absolute bottom-3 start-3 flex items-center gap-2 rounded-full bg-white/90 px-3 py-1.5 text-xs shadow-md backdrop-blur-sm">
|
||
<span className="flex h-2 w-2 rounded-full bg-brand-600 ring-2 ring-brand-200" />
|
||
<span className="font-medium text-brand-700">{markers.length} کافه و رستوران</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Export ────────────────────────────────────────────────────────────────────
|
||
|
||
export function IranMapSection() {
|
||
return (
|
||
<section className="relative overflow-hidden bg-gradient-to-b from-white to-brand-50/40 py-20 sm:py-28">
|
||
{/* Subtle background pattern */}
|
||
<div
|
||
aria-hidden
|
||
className="pointer-events-none absolute inset-0 opacity-[0.04]"
|
||
style={{
|
||
backgroundImage:
|
||
"radial-gradient(circle, #0f6e56 1px, transparent 1px)",
|
||
backgroundSize: "32px 32px",
|
||
}}
|
||
/>
|
||
|
||
<div className="relative mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||
{/* Heading */}
|
||
<div className="mb-12 text-center">
|
||
<div className="mb-4 inline-flex items-center gap-2 rounded-full border border-brand-200 bg-brand-50 px-3 py-1.5 text-xs font-semibold text-brand-700">
|
||
<span className="flex h-1.5 w-1.5 rounded-full bg-brand-500" />
|
||
پراکنش جغرافیایی
|
||
</div>
|
||
<h2 className="text-3xl font-extrabold tracking-tight text-gray-900 sm:text-4xl">
|
||
میزی در سراسر ایران
|
||
</h2>
|
||
<p className="mx-auto mt-4 max-w-xl text-base leading-relaxed text-gray-500">
|
||
از تهران تا مشهد، از تبریز تا شیراز — کافهها و رستورانهای بیشتری هر روز به میزی میپیوندند.
|
||
</p>
|
||
</div>
|
||
|
||
{/* Map */}
|
||
<div className="flex justify-center">
|
||
<Suspense
|
||
fallback={
|
||
<div
|
||
className="w-full max-w-lg animate-pulse rounded-2xl bg-brand-50"
|
||
style={{ aspectRatio: "6/5" }}
|
||
/>
|
||
}
|
||
>
|
||
<IranMapSvg />
|
||
</Suspense>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|