feat(tasarim): şehir sayfasına hero dilinde piksel harita; üniversite konum kartı da aynı harita
All checks were successful
Deploy / deploy (push) Successful in 4m39s
All checks were successful
Deploy / deploy (push) Successful in 4m39s
- scripts/il-izgara-uret.ts → src/lib/il-izgara-verisi.ts: 81 il ve 7 ülke kontürü hero'nun dot-matrix ızgarasına (72×31) rasterize; küçük iller 2×2 garanti. - SehirPikselHaritasi: iri kareler, altı opaklık, kıyı serpintisi, usePixelHeat ısı fırçası; bulunulan il iri turuncu karelerle sırayla belirir (kt-piksel-belir) ve nabız atar; KKTC/yurt dışında şehir merkezinde 2×2 iri kare. - Şehir sayfası başlığı beyaz piksel dokulu yüzeyde (SehirYuzeyi), harita sağda/altta. - UniversiteKonumHaritasi düz SVG yerine aynı piksel haritayı kullanır. - DESIGN.md §4.12/§4.17/§10.8 güncellendi. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
225
src/features/katalog/components/sehir-piksel-haritasi.tsx
Normal file
225
src/features/katalog/components/sehir-piksel-haritasi.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Şehri temsil eden piksel harita (DESIGN.md §4.17) — hero'daki dot-matrix
|
||||
* Türkiye'nin (turkey-pixel-map.tsx) şehir sayfası ve üniversite konum kartı
|
||||
* için genellenmiş hâli. Aynı dil: iri kareler, altı farklı opaklık, kıyının
|
||||
* altına seyrelen serpinti, imleç yaklaşınca turuncuya ısınan pikseller
|
||||
* (usePixelHeat). Fark: ızgara 72 sütun (her il en az 2×2 kare) ve
|
||||
* bulunulan ilin kareleri turuncu, hücrenin %88'i kadar iri (Bilal, 24 Eyl
|
||||
* 2026: "C'deki gibi piksel olabilir"), sırayla belirir ve nabız atar.
|
||||
*
|
||||
* Markup hero'daki gibi deterministik string olarak üretilir ve
|
||||
* dangerouslySetInnerHTML ile basılır: ~1300 rect React elemanı olarak
|
||||
* hydrate edilmez; server ve client aynı çıktıyı verir.
|
||||
*/
|
||||
|
||||
import { useRef } from "react";
|
||||
import { usePixelHeat } from "@/components/use-pixel-heat";
|
||||
import { ilUlkeHaritasi, type UlkeKodu } from "@/lib/harita-ulkeler";
|
||||
import {
|
||||
IL_HUCRELERI,
|
||||
IL_IZGARA,
|
||||
ULKE_HUCRELERI,
|
||||
type IzgaraHucre,
|
||||
} from "@/lib/il-izgara-verisi";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const { sutun: SUTUN, satir: SATIR, hucre: CELL } = IL_IZGARA;
|
||||
const DOT = CELL * 0.74;
|
||||
const INSET = (CELL - DOT) / 2;
|
||||
// Vurgu kareleri iri ve dolu (C seçeneğinin hissi)
|
||||
const DOT_VURGU = CELL * 0.88;
|
||||
const INSET_VURGU = (CELL - DOT_VURGU) / 2;
|
||||
// Güney kıyının altına seyrelerek dağılan serpinti derinliği (hücre)
|
||||
const SERPINTI = 5;
|
||||
const VIEW_W = SUTUN * CELL;
|
||||
const VIEW_H = (SATIR + SERPINTI) * CELL;
|
||||
// Hero'nun ısı fırçası 640 birimlik viewBox'ta 34; burada aynı oran.
|
||||
const ISI_YARICAPI = 34 * (VIEW_W / 640);
|
||||
|
||||
const OPACITIES = [0.3, 0.16, 0.24, 0.12, 0.27, 0.19];
|
||||
const opacityFor = (x: number, y: number) =>
|
||||
OPACITIES[(x * 7 + y * 11) % OPACITIES.length];
|
||||
const VURGU_OPACITIES = [1, 0.9, 0.82, 0.95];
|
||||
const vurguOpacityFor = (x: number, y: number) =>
|
||||
VURGU_OPACITIES[(x * 3 + y * 5) % VURGU_OPACITIES.length];
|
||||
|
||||
// Deterministik sözde-rastgele (hero ile aynı): server ve client aynı deseni üretir
|
||||
const hash = (x: number, y: number, seed = 0) => {
|
||||
const n = Math.sin(x * 127.1 + y * 311.7 + seed * 74.7) * 43758.5453;
|
||||
return n - Math.floor(n);
|
||||
};
|
||||
|
||||
const f = (n: number) => n.toFixed(2);
|
||||
|
||||
function zeminKare(q: number, r: number, op: number) {
|
||||
return `<rect data-px data-cx="${f(q * CELL + CELL / 2)}" data-cy="${f(r * CELL + CELL / 2)}" data-op="${op}" x="${f(q * CELL + INSET)}" y="${f(r * CELL + INSET)}" width="${f(DOT)}" height="${f(DOT)}" rx="${f(CELL * 0.2)}" fill="currentColor" fill-opacity="${op}"/>`;
|
||||
}
|
||||
|
||||
function vurguKare(q: number, r: number, i: number) {
|
||||
return `<rect class="kt-piksel-belir fill-orange-500" style="animation-delay:${i * 35}ms" x="${f(q * CELL + INSET_VURGU)}" y="${f(r * CELL + INSET_VURGU)}" width="${f(DOT_VURGU)}" height="${f(DOT_VURGU)}" rx="${f(CELL * 0.26)}" fill-opacity="${vurguOpacityFor(q, r)}"/>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zemin (mavi doku) + vurgu (turuncu) hücrelerinden statik SVG içi markup.
|
||||
* `isaret`: ülke haritasında şehir merkezi — 2×2 hücrelik tek iri kare.
|
||||
*/
|
||||
function markupUret(
|
||||
zemin: readonly IzgaraHucre[],
|
||||
vurgu: readonly IzgaraHucre[],
|
||||
isaret: IzgaraHucre | null,
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
const vurguSet = new Set(vurgu.map(([q, r]) => `${q},${r}`));
|
||||
const enAlt: number[] = Array(SUTUN).fill(-1);
|
||||
for (const [q, r] of zemin) {
|
||||
if (vurguSet.has(`${q},${r}`)) continue;
|
||||
parts.push(zeminKare(q, r, opacityFor(q, r)));
|
||||
if (r > enAlt[q]) enAlt[q] = r;
|
||||
}
|
||||
for (const [q, r] of vurgu) if (r > enAlt[q]) enAlt[q] = r;
|
||||
// Kıyının altına serpinti (hero ile aynı kural): kıyıdan uzaklaştıkça
|
||||
// seyrelen, derinlikle sönen kareler; ısı fırçasına katılırlar.
|
||||
for (let q = 0; q < SUTUN; q++) {
|
||||
if (enAlt[q] < 0) continue;
|
||||
for (let d = 1; d <= SERPINTI; d++) {
|
||||
const r = enAlt[q] + d;
|
||||
if (r >= SATIR + SERPINTI) break;
|
||||
const density = 0.05 + 0.4 * (1 - (d - 1) / SERPINTI) ** 2;
|
||||
if (hash(q, r) > density) continue;
|
||||
const op = Math.round(opacityFor(q, r) * (1 - d / (SERPINTI + 1)) * 100) / 100;
|
||||
parts.push(zeminKare(q, r, op));
|
||||
}
|
||||
}
|
||||
// Vurgu: sırayla belirir (kt-piksel-belir), grup hâlinde nabız atar
|
||||
parts.push(
|
||||
`<g class="animate-pulse motion-reduce:animate-none" style="animation-duration:3.6s">`,
|
||||
);
|
||||
vurgu.forEach(([q, r], i) => parts.push(vurguKare(q, r, i)));
|
||||
if (isaret) {
|
||||
const [q, r] = isaret;
|
||||
const boy = 2 * CELL - 2 * INSET_VURGU;
|
||||
parts.push(
|
||||
`<rect class="kt-piksel-belir fill-orange-500" x="${f(q * CELL + INSET_VURGU)}" y="${f(r * CELL + INSET_VURGU)}" width="${f(boy)}" height="${f(boy)}" rx="${f(CELL * 0.5)}"/>`,
|
||||
);
|
||||
}
|
||||
parts.push("</g>");
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
const onbellek = new Map<string, string>();
|
||||
function markup(anahtar: string, uret: () => string): string {
|
||||
let m = onbellek.get(anahtar);
|
||||
if (!m) {
|
||||
m = uret();
|
||||
onbellek.set(anahtar, m);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
function PikselSvg({
|
||||
html,
|
||||
etiket,
|
||||
className,
|
||||
}: {
|
||||
html: string;
|
||||
etiket: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
usePixelHeat(svgRef, { radius: ISI_YARICAPI });
|
||||
return (
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
|
||||
className={cn("h-auto w-full text-primary", className)}
|
||||
role="img"
|
||||
aria-label={etiket}
|
||||
// Statik, deterministik markup — her render'da yeniden kurulmaz
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const TUM_IL_HUCRELERI = Object.values(IL_HUCRELERI).flat();
|
||||
|
||||
/** Türkiye; `il` (DB biçimi, ör. "İZMİR") turuncu karelerle yanar. */
|
||||
export function TurkiyePikselHaritasi({
|
||||
il,
|
||||
etiket,
|
||||
className,
|
||||
}: {
|
||||
il: string;
|
||||
etiket: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const html = markup(`tr:${il}`, () =>
|
||||
markupUret(TUM_IL_HUCRELERI, IL_HUCRELERI[il] ?? [], null),
|
||||
);
|
||||
return <PikselSvg html={html} etiket={etiket} className={className} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ülke (KKTC, yurt dışı). Şehir konumu verilirse ülke mavi kalır ve şehirde
|
||||
* 2×2 hücrelik iri turuncu kare durur; verilmezse ülkenin tamamı turuncu.
|
||||
*/
|
||||
export function UlkePikselHaritasi({
|
||||
ulke,
|
||||
sehir,
|
||||
etiket,
|
||||
className,
|
||||
}: {
|
||||
ulke: UlkeKodu;
|
||||
/** viewBox koordinatı (harita-ulkeler'deki proje ile) */
|
||||
sehir: { x: number; y: number } | null;
|
||||
etiket: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const html = markup(
|
||||
`ulke:${ulke}:${sehir ? `${Math.round(sehir.x)},${Math.round(sehir.y)}` : "-"}`,
|
||||
() => {
|
||||
const h = ULKE_HUCRELERI[ulke];
|
||||
if (sehir) {
|
||||
const q = Math.min(SUTUN - 2, Math.max(0, Math.round(sehir.x / CELL) - 1));
|
||||
const r = Math.min(SATIR - 2, Math.max(0, Math.round(sehir.y / CELL) - 1));
|
||||
return markupUret([...h.baglam, ...h.ulke], [], [q, r]);
|
||||
}
|
||||
return markupUret(h.baglam, h.ulke, null);
|
||||
},
|
||||
);
|
||||
return <PikselSvg html={html} etiket={etiket} className={className} />;
|
||||
}
|
||||
|
||||
/** Şehir sayfası: il değerinden Türkiye ya da ülke haritasını seçer. */
|
||||
export function SehirPikselHaritasi({
|
||||
il,
|
||||
ad,
|
||||
className,
|
||||
}: {
|
||||
/** DB biçiminde il ("İSTANBUL", "LEFKOŞA", "KIBRIS") */
|
||||
il: string;
|
||||
/** Görünen ad (etiket için) */
|
||||
ad: string;
|
||||
className?: string;
|
||||
}) {
|
||||
if (IL_HUCRELERI[il]) {
|
||||
return (
|
||||
<TurkiyePikselHaritasi
|
||||
il={il}
|
||||
etiket={`${ad} Türkiye haritasında`}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const ulke = ilUlkeHaritasi(il);
|
||||
if (!ulke) return null;
|
||||
return (
|
||||
<UlkePikselHaritasi
|
||||
ulke={ulke.ulke}
|
||||
sehir={ulke.sehir ? { x: ulke.sehir.x, y: ulke.sehir.y } : null}
|
||||
etiket={`${ad} ${ulke.ulkeAd} haritasında`}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user