SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
4 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
15.0 KB · 314 lines tsx
Raw Blame History
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