spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
1"use client";23import type { ExploreQuery, ExploreResponse } from "@dci/core";4import { CONFIDENCE_LEVELS, FACILITY_STATUSES, FACILITY_TYPES, GEO_PRECISIONS, PIPELINE_STATUSES, PROJECT_CLASSES } from "@dci/core";5import { SlidersHorizontal } from "lucide-react";6import { useRouter } from "next/navigation";7import { useState, type ReactNode } from "react";8import { Button } from "@/components/ui/button";9import { cn } from "@/components/ui/cn";10import { ToggleChip } from "@/components/ui/filter-chips";11import { SegmentedControl } from "@/components/ui/segmented";12import { Sheet } from "@/components/ui/sheet";13import { AI_FILTERS, countExploreFilters, exploreHref, toggleCsv } from "@/lib/explore-query";14import { fmtInt } from "@/lib/format";15import { CONFIDENCE_SHORT, PRECISION_SHORT, STATUS_LABEL, TYPE_LABEL } from "@/lib/labels";1617const PROJECT_STATUSES = [...PIPELINE_STATUSES, "operational", "expansion", "cancelled"] as const;1819const CLASS_LABEL: Record<string, string> = {20 NEW_BUILD: "New build",21 EXPANSION: "Expansion",22 CONSTRUCTION_START: "Construction start",23 PERMIT: "Permit",24 LAND_ACQUISITION: "Land acquisition",25 GRID_CONNECTION: "Grid connection",26 POWER_AGREEMENT: "Power agreement",27 FINANCING: "Financing",28 ACQUISITION: "Acquisition",29 PARTNERSHIP: "Partnership",30 CUSTOMER_AGREEMENT: "Customer agreement",31 EXECUTIVE_APPOINTMENT: "Executive appointment",32 SUSTAINABILITY: "Sustainability",33 PRODUCT_NEWS: "Product news",34 GENERAL_COMPANY_NEWS: "Company news",35 UNKNOWN: "Unknown",36};3738const AI_LABEL: Record<(typeof AI_FILTERS)[number], string> = { any: "Any", confirmed: "Confirmed", likely: "≥ Likely", associated: "≥ Associated" };3940const inputCls = "h-10 w-full min-w-0 rounded-sm border border-hair-2 bg-surface px-2.5 text-[13px] text-ink outline-none focus:border-ink sm:h-9";4142function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {43 return (44 <label className="block min-w-0">45 <span className="label-caps mb-1 block">{label}</span>46 {children}47 {hint && <span className="mt-0.5 block text-[11px] text-ink-4">{hint}</span>}48 </label>49 );50}5152function Group({ legend, children }: { legend: string; children: ReactNode }) {53 return (54 <fieldset className="border-t border-hair py-3 first:border-t-0 first:pt-0">55 <legend className="label-caps mb-2">{legend}</legend>56 {children}57 </fieldset>58 );59}6061interface Props {62 query: ExploreQuery;63 facets: ExploreResponse["facets"] | null;64}6566/**67 * Structured query builder (URL-as-state). Desktop: inline left column; mobile: a Filters button opening a sheet.68 * Chips apply immediately; text/number inputs apply on blur / Enter (and via the sheet's Apply).69 */70export function QueryBuilder({ query, facets }: Props) {71 return (72 <>73 <div className="hidden lg:block">74 <Form query={query} facets={facets} />75 </div>76 <MobileFilters query={query} facets={facets} />77 </>78 );79}8081function MobileFilters({ query, facets }: Props) {82 const [open, setOpen] = useState(false);83 const active = countExploreFilters(query);84 return (85 <div className="lg:hidden">86 <Button size="sm" variant={active ? "primary" : "secondary"} onClick={() => setOpen(true)} aria-haspopup="dialog">87 <SlidersHorizontal className="size-3.5" strokeWidth={1.75} /> Filters{active > 0 && <span className="figure ml-0.5 rounded-xs bg-paper/20 px-1 text-[11px]">{active}</span>}88 </Button>89 <Sheet open={open} onClose={() => setOpen(false)} title="Build a query" description="Every field maps to a URL parameter — the address bar is the saved view." heightVh={86}>90 <Form query={query} facets={facets} onNavigate={() => setOpen(false)} />91 </Sheet>92 </div>93 );94}9596function Form({ query, facets, onNavigate }: Props & { onNavigate?: () => void }) {97 const router = useRouter();98 const isProjects = query.entity === "projects";99 const [draft, setDraft] = useState<ExploreQuery>(query);100 const [synced, setSynced] = useState(query);101 if (synced !== query) {102 setSynced(query);103 setDraft(query);104 }105106 const go = (patch: Partial<ExploreQuery> & Record<string, unknown>) => {107 router.replace(exploreHref(query, { ...patch, page: undefined }), { scroll: false });108 onNavigate?.();109 };110 const commit = () => go({ ...draft });111 const csvOn = (key: keyof ExploreQuery, member: string) => ((draft[key] as string | undefined) ?? "").split(",").includes(member);112 const flip = (key: keyof ExploreQuery, member: string) => go({ [key]: toggleCsv(query[key] as string | undefined, member) });113 const onKey = (e: React.KeyboardEvent) => {114 if (e.key === "Enter") {115 e.preventDefault();116 commit();117 }118 };119 const setNum = (key: keyof ExploreQuery) => (e: React.ChangeEvent<HTMLInputElement>) => {120 const v = e.target.value;121 setDraft({ ...draft, [key]: v === "" ? undefined : Number(v) });122 };123 const statuses = isProjects ? PROJECT_STATUSES : FACILITY_STATUSES;124 const countryFacet = (facets?.country ?? []).slice(0, 12);125 const operatorFacet = (facets?.operator ?? []).slice(0, 8);126127 return (128 <form129 className="flex flex-col"130 onSubmit={(e) => {131 e.preventDefault();132 commit();133 }}134 >135 <Group legend="Entity">136 <SegmentedControl137 label="Entity"138 value={isProjects ? "projects" : "facilities"}139 onChange={(v) => {140 router.replace(exploreHref({ entity: v as ExploreQuery["entity"], view: query.view, q: query.q, country: query.country, operator: query.operator, metro: query.metro }), { scroll: false });141 onNavigate?.();142 }}143 options={[144 { value: "facilities", label: "Facilities" },145 { value: "projects", label: "Projects" },146 ]}147 className="w-full [&>*]:flex-1"148 />149 </Group>150151 <Group legend="Search">152 <input value={draft.q ?? ""} onChange={(e) => setDraft({ ...draft, q: e.target.value || undefined })} onBlur={commit} onKeyDown={onKey} placeholder={isProjects ? "Project, operator or city" : "Facility, operator or city"} className={inputCls} aria-label="Free text" />153 </Group>154155 <Group legend="Status">156 <div className="flex flex-wrap gap-1.5">157 {statuses.map((s) => (158 <ToggleChip key={s} active={csvOn("status", s)} onClick={() => flip("status", s)} className="h-9 text-[12px] sm:h-7">159 {STATUS_LABEL[s]}160 </ToggleChip>161 ))}162 </div>163 </Group>164165 <Group legend="Place">166 <div className="grid gap-2 sm:grid-cols-2">167 <Field label="Country (ISO2, comma list)">168 <input value={draft.country ?? ""} onChange={(e) => setDraft({ ...draft, country: e.target.value.toUpperCase().replace(/[^A-Z,]/g, "") || undefined })} onBlur={commit} onKeyDown={onKey} placeholder="US,CA" className={cn(inputCls, "figure")} />169 </Field>170 <Field label="Market slug">171 <input value={draft.metro ?? ""} onChange={(e) => setDraft({ ...draft, metro: e.target.value.trim() || undefined })} onBlur={commit} onKeyDown={onKey} placeholder="northern-virginia" className={cn(inputCls, "figure")} />172 </Field>173 </div>174 {countryFacet.length > 0 && (175 <div className="mt-2 flex flex-wrap gap-1.5" aria-label="Countries in the current result">176 {countryFacet.map((c) => (177 <ToggleChip key={c.key} active={csvOn("country", c.key)} onClick={() => flip("country", c.key)} className="h-9 text-[12px] sm:h-7">178 {c.name} <span className="figure text-[11px] opacity-70">{fmtInt(c.count)}</span>179 </ToggleChip>180 ))}181 </div>182 )}183 </Group>184185 <Group legend="Operator">186 <input value={draft.operator ?? ""} onChange={(e) => setDraft({ ...draft, operator: e.target.value.trim() || undefined })} onBlur={commit} onKeyDown={onKey} placeholder="equinix" className={cn(inputCls, "figure")} aria-label="Operator slug" />187 {operatorFacet.length > 0 && (188 <div className="mt-2 flex flex-wrap gap-1.5" aria-label="Operators in the current result">189 {operatorFacet.map((o) => (190 <ToggleChip key={o.key} active={query.operator === o.key} onClick={() => go({ operator: query.operator === o.key ? undefined : o.key })} className="h-9 text-[12px] sm:h-7">191 {o.name} <span className="figure text-[11px] opacity-70">{fmtInt(o.count)}</span>192 </ToggleChip>193 ))}194 </div>195 )}196 </Group>197198 {!isProjects && (199 <Group legend="Facility type">200 <div className="flex flex-wrap gap-1.5">201 {FACILITY_TYPES.map((t) => (202 <ToggleChip key={t} active={csvOn("type", t)} onClick={() => flip("type", t)} className="h-9 text-[12px] sm:h-7">203 {TYPE_LABEL[t]}204 </ToggleChip>205 ))}206 </div>207 </Group>208 )}209210 <Group legend="Capacity (published MW)">211 <div className="grid grid-cols-2 gap-2">212 <Field label="Min MW">213 <input type="number" min={0} inputMode="decimal" value={draft.min_mw ?? ""} onChange={setNum("min_mw")} onBlur={commit} onKeyDown={onKey} className={cn(inputCls, "figure")} />214 </Field>215 <Field label="Max MW">216 <input type="number" min={0} inputMode="decimal" value={draft.max_mw ?? ""} onChange={setNum("max_mw")} onBlur={commit} onKeyDown={onKey} className={cn(inputCls, "figure")} />217 </Field>218 </div>219 <div className="mt-2 flex flex-wrap gap-1.5">220 <ToggleChip active={query.has_mw === true} onClick={() => go({ has_mw: query.has_mw ? undefined : true })} className="h-9 text-[12px] sm:h-7">221 Known MW only222 </ToggleChip>223 {!isProjects && (224 <ToggleChip active={query.hyperscale === true} onClick={() => go({ hyperscale: query.hyperscale ? undefined : true })} className="h-9 text-[12px] sm:h-7">225 Hyperscale226 </ToggleChip>227 )}228 </div>229 </Group>230231 <Group legend="AI evidence">232 <SegmentedControl label="AI evidence" value={query.ai ?? "any"} onChange={(v) => go({ ai: v === "any" ? undefined : (v as ExploreQuery["ai"]) })} options={AI_FILTERS.map((a) => ({ value: a, label: AI_LABEL[a] }))} className="w-full [&>*]:flex-1" size="xs" />233 <p className="mt-1 text-[11px] text-ink-4">Confirmed ⊂ likely ⊂ associated; “associated” alone is a tenant / customer mention, not AI infrastructure.</p>234 </Group>235236 {isProjects ? (237 <>238 <Group legend="Timing">239 <div className="grid grid-cols-2 gap-2">240 <Field label="Expected after (year)">241 <input type="number" min={2000} max={2100} inputMode="numeric" value={draft.expected_after ?? ""} onChange={setNum("expected_after")} onBlur={commit} onKeyDown={onKey} className={cn(inputCls, "figure")} />242 </Field>243 <Field label="Expected before (year)">244 <input type="number" min={2000} max={2100} inputMode="numeric" value={draft.expected_before ?? ""} onChange={setNum("expected_before")} onBlur={commit} onKeyDown={onKey} className={cn(inputCls, "figure")} />245 </Field>246 </div>247 <div className="mt-2">248 <Field label="Announced since">249 <input type="date" value={draft.announced_since ?? ""} onChange={(e) => setDraft({ ...draft, announced_since: e.target.value || undefined })} onBlur={commit} className={cn(inputCls, "figure")} />250 </Field>251 </div>252 </Group>253 <Group legend="Announcement class">254 <div className="flex flex-wrap gap-1.5">255 {PROJECT_CLASSES.map((c) => (256 <ToggleChip key={c} active={csvOn("project_class", c)} onClick={() => flip("project_class", c)} className="h-9 text-[12px] sm:h-7">257 {CLASS_LABEL[c] ?? c}258 </ToggleChip>259 ))}260 </div>261 </Group>262 </>263 ) : (264 <>265 <Group legend="Opened">266 <div className="grid grid-cols-2 gap-2">267 <Field label="From (year)">268 <input type="number" min={1950} max={2100} inputMode="numeric" value={draft.opened_from ?? ""} onChange={setNum("opened_from")} onBlur={commit} onKeyDown={onKey} className={cn(inputCls, "figure")} />269 </Field>270 <Field label="To (year)">271 <input type="number" min={1950} max={2100} inputMode="numeric" value={draft.opened_to ?? ""} onChange={setNum("opened_to")} onBlur={commit} onKeyDown={onKey} className={cn(inputCls, "figure")} />272 </Field>273 </div>274 <p className="mt-1 text-[11px] text-ink-4">Facilities without a published opening date never match a year range.</p>275 </Group>276 <Group legend="Location precision">277 <div className="flex flex-wrap gap-1.5">278 {GEO_PRECISIONS.map((p) => (279 <ToggleChip key={p} active={csvOn("location_precision", p)} onClick={() => flip("location_precision", p)} className="h-9 text-[12px] sm:h-7">280 {PRECISION_SHORT[p]}281 </ToggleChip>282 ))}283 </div>284 </Group>285 </>286 )}287288 <Group legend="Confidence">289 <div className="flex flex-wrap gap-1.5">290 {CONFIDENCE_LEVELS.map((c) => (291 <ToggleChip key={c} active={csvOn("confidence", c)} onClick={() => flip("confidence", c)} className="h-9 text-[12px] sm:h-7">292 {CONFIDENCE_SHORT[c]}293 </ToggleChip>294 ))}295 </div>296 </Group>297298 <div className="flex items-center justify-between gap-2 border-t border-hair pt-3">299 <Button variant="ghost" size="sm" type="button" onClick={() => go({ status: undefined, country: undefined, metro: undefined, operator: undefined, type: undefined, min_mw: undefined, max_mw: undefined, ai: undefined, hyperscale: undefined, expected_before: undefined, expected_after: undefined, opened_from: undefined, opened_to: undefined, announced_since: undefined, confidence: undefined, location_precision: undefined, has_mw: undefined, project_class: undefined, q: undefined })}>300 Reset301 </Button>302 <Button variant="primary" size="sm" type="submit">303 Apply304 </Button>305 </div>306 </form>307 );308}309