spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
1"use client";23import type { FacilityFilters, FacilityStatus, FacilityType, SearchResponse } from "@dci/core";4import { FACILITY_TYPES } from "@dci/core";5import { SlidersHorizontal } from "lucide-react";6import { usePathname, useRouter, useSearchParams } from "next/navigation";7import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";8import { Button } from "@/components/ui/button";9import { cn } from "@/components/ui/cn";10import { FilterChips, ToggleChip, type Chip } from "@/components/ui/filter-chips";11import { SearchInput } from "@/components/ui/search-input";12import { Sheet } from "@/components/ui/sheet";13import { StatusDot } from "@/components/ui/status-dot";14import { apiGet, clientApiBase } from "@/lib/api";15import { useDebounced } from "@/lib/hooks";16import { STATUS_LABEL, STATUS_ORDER, TYPE_LABEL } from "@/lib/labels";17import { countActiveFilters, csv, describeFilters, filtersToQuery, parseFilters, removeFilter } from "@/lib/query";1819/* ------------------------------------------------------------------------------------------20 Shared facility filter set (map, /datacenters, entity pages). Two pieces:21 - <FacilityFilterFields> : the controls, driven by a (filters, onChange) pair (used inline in22 the map's desktop panel and inside the sheet).23 - <FacilityFilterBar> : URL-synced chips + "Filters" button opening a Sheet (bottom on mobile).24 ------------------------------------------------------------------------------------------ */2526export const MW_MAX = 500;27const YEAR_MIN = 1990;28const YEAR_MAX = new Date().getUTCFullYear() + 8;2930export interface FilterFieldsProps {31 value: FacilityFilters;32 onChange: (next: FacilityFilters) => void;33 /** hide some groups (e.g. cloud toggle) */34 hide?: Array<"status" | "type" | "operator" | "country" | "mw" | "years" | "flags" | "cloud" | "q">;35 className?: string;36 /** extra non-facility toggles (map: cloud regions layer) */37 extra?: ReactNode;38}3940function Group({ label, children, hint }: { label: string; children: ReactNode; hint?: string }) {41 return (42 <fieldset className="border-t border-hair py-3 first:border-t-0 first:pt-0">43 <legend className="label-caps float-left mb-2 w-full">{label}</legend>44 <div className="clear-both">45 {children}46 {hint && <p className="mt-1.5 text-[11px] text-ink-4">{hint}</p>}47 </div>48 </fieldset>49 );50}5152const TYPE_CHOICES: FacilityType[] = FACILITY_TYPES.filter((t) => t !== "unknown");5354function RangeInputs({ lo, hi, min, max, step = 1, unit, onChange, labels }: { lo: number | undefined; hi: number | undefined; min: number; max: number; step?: number; unit?: string; onChange: (lo: number | undefined, hi: number | undefined) => void; labels: [string, string] }) {55 const l = lo ?? min;56 const h = hi ?? max;57 return (58 <div>59 <div className="flex items-center gap-2">60 <label className="flex-1">61 <span className="sr-only">{labels[0]}</span>62 <input type="number" inputMode="numeric" min={min} max={max} step={step} value={lo ?? ""} placeholder={String(min)} onChange={(e) => onChange(e.target.value === "" ? undefined : Number(e.target.value), hi)} className="figure h-9 w-full rounded-sm border border-hair-2 bg-surface px-2 text-[13px] outline-none focus:border-ink" />63 </label>64 <span className="text-ink-4">–</span>65 <label className="flex-1">66 <span className="sr-only">{labels[1]}</span>67 <input type="number" inputMode="numeric" min={min} max={max} step={step} value={hi ?? ""} placeholder={`${max}${unit === "MW" ? "+" : ""}`} onChange={(e) => onChange(lo, e.target.value === "" ? undefined : Number(e.target.value))} className="figure h-9 w-full rounded-sm border border-hair-2 bg-surface px-2 text-[13px] outline-none focus:border-ink" />68 </label>69 {unit && <span className="w-8 text-[12px] text-ink-3">{unit}</span>}70 </div>71 <div className="relative mt-3 h-6">72 <input type="range" aria-label={labels[0]} min={min} max={max} step={step} value={l} onChange={(e) => onChange(Math.min(Number(e.target.value), h), hi)} className="dual-range absolute inset-x-0 top-0 h-6 w-full appearance-none bg-transparent" />73 <input type="range" aria-label={labels[1]} min={min} max={max} step={step} value={h} onChange={(e) => onChange(lo, Math.max(Number(e.target.value), l))} className="dual-range absolute inset-x-0 top-0 h-6 w-full appearance-none bg-transparent" />74 <div className="pointer-events-none absolute inset-x-0 top-[11px] h-[2px] bg-surface-3">75 <div className="absolute h-full bg-ink" style={{ left: `${((l - min) / (max - min)) * 100}%`, right: `${100 - ((h - min) / (max - min)) * 100}%` }} />76 </div>77 </div>78 </div>79 );80}8182/** Async operator search-select backed by /search (type=operator). */83function OperatorSelect({ value, onChange }: { value: string | undefined; onChange: (slug: string | undefined, name?: string) => void }) {84 const [q, setQ] = useState("");85 const dq = useDebounced(q.trim(), 180);86 const [hits, setHits] = useState<Array<{ slug: string; title: string; subtitle: string | null }>>([]);87 useEffect(() => {88 if (dq.length < 2) return;89 const ctrl = new AbortController();90 apiGet<SearchResponse>("/search", { q: dq, type: "operator" }, { base: clientApiBase(), signal: ctrl.signal, timeoutMs: 5000 }).then((r) => {91 if (!ctrl.signal.aborted && r.ok) setHits(r.data.hits.filter((h) => h.type === "operator").map((h) => ({ slug: h.slug, title: h.title, subtitle: h.subtitle })));92 });93 return () => ctrl.abort();94 }, [dq]);95 return (96 <div>97 {value ? (98 <div className="flex items-center justify-between gap-2 rounded-sm border border-hair-2 bg-surface-2 px-2.5 py-1.5 text-[13px]">99 <span className="truncate font-medium">{value}</span>100 <button type="button" className="text-[12px] text-ink-3 hover:text-ink" onClick={() => onChange(undefined)}>101 Clear102 </button>103 </div>104 ) : (105 <>106 <SearchInput size="sm" value={q} onChange={setQ} placeholder="Search operators…" aria-label="Operator" />107 {dq.length >= 2 && hits.length > 0 && (108 <ul className="mt-1 max-h-48 overflow-y-auto rounded-sm border border-hair bg-surface">109 {hits.map((h) => (110 <li key={h.slug}>111 <button112 type="button"113 className="flex w-full flex-col px-2.5 py-1.5 text-left hover:bg-surface-2"114 onClick={() => {115 onChange(h.slug, h.title);116 setQ("");117 setHits([]);118 }}119 >120 <span className="text-[13px]">{h.title}</span>121 {h.subtitle && <span className="text-[11px] text-ink-3">{h.subtitle}</span>}122 </button>123 </li>124 ))}125 </ul>126 )}127 </>128 )}129 </div>130 );131}132133export function FacilityFilterFields({ value, onChange, hide = [], className, extra }: FilterFieldsProps) {134 const statuses = csv<FacilityStatus>(value.status);135 const types = csv<FacilityType>(value.type);136 const toggleCsv = (key: "status" | "type", member: string) => {137 const cur = csv(value[key]);138 const next = cur.includes(member) ? cur.filter((x) => x !== member) : [...cur, member];139 onChange({ ...value, [key]: next.length ? next.join(",") : undefined, page: undefined });140 };141 const set = (patch: Partial<FacilityFilters>) => onChange({ ...value, ...patch, page: undefined });142 const show = (g: NonNullable<FilterFieldsProps["hide"]>[number]) => !hide.includes(g);143144 return (145 <div className={cn("flex flex-col", className)}>146 {show("q") && (147 <Group label="Keyword">148 <SearchInput size="sm" value={value.q ?? ""} onChange={(v) => set({ q: v || undefined })} placeholder="Name, city, alias…" aria-label="Keyword" />149 </Group>150 )}151 {show("status") && (152 <Group label="Status">153 <div className="flex flex-wrap gap-1.5">154 {STATUS_ORDER.filter((s) => s !== "unknown").map((s) => (155 <ToggleChip key={s} active={statuses.includes(s)} onClick={() => toggleCsv("status", s)} dot={<StatusDot status={s} size={7} />}>156 {STATUS_LABEL[s]}157 </ToggleChip>158 ))}159 </div>160 </Group>161 )}162 {show("type") && (163 <Group label="Type">164 <div className="flex flex-wrap gap-1.5">165 {TYPE_CHOICES.map((t) => (166 <ToggleChip key={t} active={types.includes(t)} onClick={() => toggleCsv("type", t)}>167 {TYPE_LABEL[t]}168 </ToggleChip>169 ))}170 </div>171 </Group>172 )}173 {show("flags") && (174 <Group label="Segment">175 <div className="flex flex-wrap gap-1.5">176 <ToggleChip active={!!value.ai} onClick={() => set({ ai: value.ai ? undefined : true })}>177 AI infrastructure178 </ToggleChip>179 <ToggleChip active={!!value.hyperscale} onClick={() => set({ hyperscale: value.hyperscale ? undefined : true })}>180 Hyperscale181 </ToggleChip>182 <ToggleChip active={!!value.colocation} onClick={() => set({ colocation: value.colocation ? undefined : true })}>183 Colocation184 </ToggleChip>185 <ToggleChip active={!!value.renewable} onClick={() => set({ renewable: value.renewable ? undefined : true })}>186 Renewable claim187 </ToggleChip>188 <ToggleChip active={!!value.has_mw} onClick={() => set({ has_mw: value.has_mw ? undefined : true })}>189 Known MW only190 </ToggleChip>191 </div>192 {extra}193 </Group>194 )}195 {show("operator") && (196 <Group label="Operator">197 <OperatorSelect value={value.operator} onChange={(slug) => set({ operator: slug })} />198 </Group>199 )}200 {show("country") && (201 <Group label="Country" hint="ISO code, e.g. US, DE, JP — comma-separated for several.">202 <input value={value.country ?? ""} onChange={(e) => set({ country: e.target.value.toUpperCase().replace(/[^A-Z,]/g, "") || undefined })} placeholder="US, CA, IE…" aria-label="Country codes" maxLength={29} className="figure h-9 w-full rounded-sm border border-hair-2 bg-surface px-2.5 text-[13px] uppercase outline-none placeholder:normal-case placeholder:text-ink-4 focus:border-ink" />203 </Group>204 )}205 {show("mw") && (206 <Group label="Capacity (MW)" hint="Best known figure (IT, total or planned). 500 = 500 MW and above.">207 <RangeInputs lo={value.min_mw} hi={value.max_mw} min={0} max={MW_MAX} step={5} unit="MW" labels={["Minimum MW", "Maximum MW"]} onChange={(lo, hi) => set({ min_mw: lo, max_mw: hi === MW_MAX ? undefined : hi })} />208 </Group>209 )}210 {show("years") && (211 <Group label="Opening year">212 <RangeInputs lo={value.opened_from} hi={value.opened_to} min={YEAR_MIN} max={YEAR_MAX} labels={["Opened from", "Opened to"]} onChange={(lo, hi) => set({ opened_from: lo === YEAR_MIN ? undefined : lo, opened_to: hi === YEAR_MAX ? undefined : hi })} />213 </Group>214 )}215 {show("cloud") && (216 <Group label="Cloud provider" hint="Provider slug, e.g. aws, microsoft-azure, google-cloud.">217 <input value={value.cloud ?? ""} onChange={(e) => set({ cloud: e.target.value || undefined })} placeholder="aws" aria-label="Cloud provider slug" className="h-9 w-full rounded-sm border border-hair-2 bg-surface px-2.5 text-[13px] outline-none placeholder:text-ink-4 focus:border-ink" />218 </Group>219 )}220 </div>221 );222}223224/* ---------------------------------------------------------------- URL-synced bar + sheet */225226export interface FacilityFilterBarProps {227 /** path the chips/sheet write to (default: current) */228 basePath?: string;229 total?: number | null;230 hide?: FilterFieldsProps["hide"];231 className?: string;232 /** preserve these params when rewriting the URL (e.g. map view) */233 keep?: string[];234 children?: ReactNode;235}236237export function useUrlFilters(basePath?: string, keep: string[] = []) {238 const router = useRouter();239 const pathname = usePathname();240 const sp = useSearchParams();241 const filters = useMemo(() => parseFilters(sp), [sp]);242 const push = useCallback(243 (next: FacilityFilters) => {244 const q = new URLSearchParams(filtersToQuery(next));245 for (const k of keep) {246 const v = sp.get(k);247 if (v !== null) q.set(k, v);248 }249 const s = q.toString();250 router.replace(`${basePath ?? pathname}${s ? `?${s}` : ""}`, { scroll: false });251 },252 [router, pathname, basePath, sp, keep],253 );254 return { filters, push, sp };255}256257export function FacilityFilterBar({ basePath, total, hide, className, keep, children }: FacilityFilterBarProps) {258 const { filters, push } = useUrlFilters(basePath, keep);259 const [open, setOpen] = useState(false);260 const [draft, setDraft] = useState<FacilityFilters>(filters);261 const [synced, setSynced] = useState(filters);262 if (synced !== filters) {263 setSynced(filters);264 setDraft(filters);265 }266 const active = countActiveFilters(filters);267 const chips: Chip[] = describeFilters(filters, { status: STATUS_LABEL, type: TYPE_LABEL }).map((c) => ({ key: `${c.key}:${c.member ?? c.value}`, label: c.label, value: c.value, onRemove: () => push(removeFilter(filters, c)) }));268269 return (270 <div className={cn("flex flex-col gap-2", className)}>271 <div className="flex items-center gap-2">272 <Button size="sm" variant={active ? "primary" : "secondary"} onClick={() => setOpen(true)} aria-haspopup="dialog" aria-expanded={open}>273 <SlidersHorizontal className="size-3.5" strokeWidth={1.75} />274 Filters275 {active > 0 && <span className="figure ml-0.5 rounded-xs bg-paper/20 px-1 text-[11px]">{active}</span>}276 </Button>277 {children}278 {total !== undefined && total !== null && (279 <span className="figure ml-auto text-[12.5px] text-ink-3" aria-live="polite">280 {new Intl.NumberFormat("en-US").format(total)} {total === 1 ? "facility" : "facilities"}281 </span>282 )}283 </div>284 <FilterChips chips={chips} onReset={() => push({ sort: filters.sort, order: filters.order })} />285 <Sheet286 open={open}287 onClose={() => setOpen(false)}288 title="Filter facilities"289 description={total !== undefined && total !== null ? `${new Intl.NumberFormat("en-US").format(total)} matching` : undefined}290 heightVh={82}291 footer={292 <div className="flex items-center justify-between gap-2">293 <Button variant="ghost" size="md" onClick={() => setDraft({ sort: filters.sort, order: filters.order })}>294 Reset295 </Button>296 <Button297 variant="primary"298 size="md"299 onClick={() => {300 push(draft);301 setOpen(false);302 }}303 >304 Apply filters305 </Button>306 </div>307 }308 >309 <FacilityFilterFields value={draft} onChange={setDraft} hide={hide} />310 </Sheet>311 </div>312 );313}314