PageSourceSearch

https://caldariprimeponyclub.com/skinr/skinr.mjs

js caldariprimeponyclub.com collected 2026-10-07 19:36:41 UTC 54,385 bytes, 2,063 lines download raw bytes

1import { SetImage } from "../src/ui/ImageRepository.mjs";
2
3import { CountSpins } from "../src/ui/Toasty.mjs";
4import { AssetWindow } from "../src/ui/AssetWindow.mjs";
5import { DeclarePane } from "../src/ui/AssetPane.mjs";
6import { AuthorCaption } from "../src/ui/AuthorCaption.mjs";
7import { ComponentGrid } from "../src/skinr/ComponentGrid.mjs";
8import { DnaStage } from "../src/dna/DnaStage.mjs";
9import { CurrentSofSource, SkinrAvailable } from "../src/api/SiteSource.mjs";
10import { UiChromeUrl } from "../src/api/Icons.mjs";
11import { HasParagonHub, UnavailableReason } from "../src/api/SourceFeatures.mjs";
12import { Dropdown } from "../src/ui/Dropdown.mjs";
13import { EveTypeIconURL, EveTypeRenderURL } from "../src/api/EveImageServer.mjs";
14import { UNRESPONSIVE } from "../src/api/SiteFetch.mjs";
15import { Busy } from "../src/ui/GridBusy.mjs";
16import { PageSettings } from "../src/PageSettings.mjs";
17import { ShareLink } from "../src/dna/DnaAddress.mjs";
18import { Share } from "../src/ui/ShareDialog.mjs";
19import { Settings } from "../src/Settings.mjs";
20import { PlexPrice } from "../src/PlexPrice.mjs";
21import { RibbonMenu } from "../src/ui/RibbonMenu.mjs";
22import "../src/ui/OwnershipDisplay.mjs";
23import { CostTip } from "../src/skinr/CostTip.mjs";
24import { StageTools, ToolRow } from "../src/ui/StageTools.mjs";
25import { BannerControl } from "../src/ui/BannerControl.mjs";
26import { PostProcessControl } from "../src/ui/PostProcessControl.mjs";
27import { KillmarkControl } from "../src/ui/KillmarkControl.mjs";
28import { StageFullscreen } from "../src/ui/StageFullscreen.mjs";
29import { CameraMotionControl } from "../src/ui/CameraMotionControl.mjs";
30import { SunControl } from "../src/ui/SunControl.mjs";
31import { SceneLightsControl } from "../src/ui/SceneLightsControl.mjs";
32import { AutoRotateControl } from "../src/ui/AutoRotateControl.mjs";
33import { InstallWheelInvert } from "../src/ui/WheelInvert.mjs";
34import { SceneReflectionControl } from "../src/ui/SceneReflectionControl.mjs";
35import { ShipAnnotations } from "../src/render/ShipAnnotations.mjs";
36import { SceneCustomControl } from "../src/ui/SceneCustomControl.mjs";
37import { StageMemory } from "../src/ui/StageMemory.mjs";
38import { Json } from "../src/api/Json.mjs";
39import { CameraAngleControl } from "../src/ui/CameraAngleControl.mjs";
40import { DesignCost } from "../src/skinr/DesignCostClient.mjs";
41import { ColumnResizer } from "../src/ui/ColumnResizer.mjs";
42import { nebulaCatalogue } from "../src/api/Nebulae.mjs";
43import { ViewSettings } from "../src/ui/ViewSettings.mjs";
44import { SkinrCardElement, SkinrRenderURL } from "../src/skinr/SkinrCardElement.mjs";
45import { NUMERIC_GLYPHS, TextScramble } from "../src/ui/TextScramble.mjs";
46import { UiLayers } from "../src/ui/LayerStack.mjs";
47import { Me } from "../src/api/Me.mjs";
48import { AttachSearchClear } from "../src/ui/SearchClear.mjs";
49import { Remember } from "../src/PageMemory.mjs";
50import { Ownership } from "../src/api/Ownership.mjs";
51
52const FINDING_CAPSULEER = "FINDING CAPSULEER";
53
54const PAGE_SIZE = 1000;
55
56const SORT_DESCENDS = Object.freeze({
57    recent: true,
58    tier: true,
59    price: false,
60    name: false,
61    hull: false
62});
63
64const PLEX_TYPE_ID = 44992;
65
66const GROUPS = Object.freeze({
67    none: null,
68    creator: entry => CharacterLabel(entry.creatorId, "NO CREATOR", entry.creatorName),
69    seller: entry => CharacterLabel(entry.sellerId, "NO SELLER", entry.sellerName),
70    ship: entry => HullHeading(entry),
71    category: entry => (entry.category || "UNCLASSIFIED").toUpperCase(),
72
73    tier: entry => (entry.tierLevel ? `TIER ${String(entry.tierLevel).padStart(2, "0")}` : "NO TIER"),
74    currency: entry => (entry.price?.kind ?? "no price").toUpperCase(),
75    state: entry => (entry.state ?? "unknown").replace(/_/gu, " ").toUpperCase()
76});
77
78function CharacterLabel(characterId, absent, known = null)
79{
80
81    if (known) return String(known).toUpperCase();
82
83    const id = Number(characterId);
84
85    if (!Number.isFinite(id) || id <= 0) return absent;
86
87    const held = identities.get(id);
88    const name = held && typeof held.then !== "function" ? held?.name : null;
89
90    return (name ?? FINDING_CAPSULEER).toUpperCase();
91}
92
93const MODES = [
94    {
95        name: "icons",
96        title: "Tiles — click for the list"
97    },
98    {
99        name: "list",
100        title: "List — click for tiles"
101    }
102];
103
104const paneEl = document.getElementById("skinr-pane");
105const filtersEl = document.getElementById("skinr-filters");
106const columnsEl = document.getElementById("skinr-grid").parentElement;
107const gridHostEl = document.getElementById("skinr-grid");
108const statusEl = document.getElementById("skinr-status");
109const searchEl = document.getElementById("skinr-search");
110
111const modeEl = null;
112
113const nameEl = document.getElementById("skinr-foot-name");
114const subEl = document.getElementById("skinr-foot-sub");
115const shipIconEl = document.getElementById("skinr-detail-ship");
116const pricesEl = document.getElementById("skinr-foot-prices");
117const stateEl = document.getElementById("skinr-foot-state");
118
119const detailEl = document.getElementById("skinr-detail");
120
121const previewEl = document.getElementById("skinr-preview");
122
123const author = new AuthorCaption({ element: document.getElementById("skinr-detail-author") });
124
125let sdeBuild = null;
126
127const stageTools = new StageTools({
128    collapsed: true,
129    storageKey: "skindr:hub:tools",
130
131    icon: path => UiChromeUrl(`res:/ui/texture/${path}`),
132    lookAt: kind => previewStage.renderer?.LookAtDecal?.(kind)
133});
134
135const skinrToolsHostEl = document.getElementById("skinr-tools");
136
137skinrToolsHostEl.appendChild(stageTools.element);
138
139const fullscreen = new StageFullscreen({ stage: document.getElementById("skinr-stage"), host: skinrToolsHostEl });
140
141const hubStage = new StageMemory({ key: "cppc.stage.hub" });
142
143new CameraMotionControl({ tools: stageTools, renderer: () => previewStage.renderer, memory: hubStage });
144
145const sunControl = new SunControl({ tools: stageTools, renderer: () => previewStage.renderer, memory: hubStage });
146
147new SceneLightsControl({
148    tools: stageTools,
149    renderer: () => previewStage.renderer,
150    memory: hubStage,
151    host: skinrToolsHostEl,
152    before: fullscreen.button,
153    defaultOn: true
154});
155
156new AutoRotateControl({
157    tools: stageTools,
158    renderer: () => previewStage.renderer,
159    memory: hubStage,
160    host: skinrToolsHostEl,
161    before: fullscreen.button
162});
163
164InstallWheelInvert(() => Settings.Load().invertWheel === true);
165
166const nebulaEl = document.createElement("select");
167
168const BACKGROUNDS = Object.freeze({
169    none: null,
170    black: [ 0, 0, 0, 1 ]
171});
172
173const backgroundEl = document.createElement("select");
174
175for (const name of Object.keys(BACKGROUNDS))
176{
177    const option = document.createElement("option");
178
179    option.value = name;
180    option.textContent = name.toUpperCase();
181    backgroundEl.appendChild(option);
182}
183
184function ApplySky()
185{
186    const colour = BACKGROUNDS[backgroundEl.value] ?? null;
187
188    previewStage.renderer?.SetEnvironment?.(!colour);
189
190    if (colour) previewStage.renderer?.SetClearColour?.(colour);
191}
192
193backgroundEl.addEventListener("change", ApplySky);
194
195let stagedNebula = null;
196
197let nebulaPaths = [];
198
199function RollNebula()
200{
201
202    const pool = nebulaPaths.filter(path => !/empty_background/i.test(path));
203
204    return pool.length
205        ? pool[Math.floor(Math.random() * pool.length)]
206        : "";
207}
208
209async function ApplyScene()
210{
211    const wanted = nebulaEl.value || stagedNebula || RollNebula();
212
213    if (wanted && wanted !== stagedNebula)
214    {
215        stagedNebula = wanted;
216        await previewStage.SetNebula(wanted).catch(() => { stagedNebula = null; });
217        sunControl.RestoreFlare();
218    }
219
220    ApplySky();
221}
222
223backgroundEl.value = "black";
224
225hubStage.Bind(backgroundEl, "background");
226
227nebulaEl.addEventListener("change", async () =>
228{
229
230    stagedNebula = nebulaEl.value || RollNebula();
231    await previewStage.SetNebula(stagedNebula);
232    sunControl.RestoreFlare();
233
234    ApplySky();
235});
236
237new SceneCustomControl({ tools: stageTools, renderer: () => previewStage.renderer, memory: hubStage });
238
239stageTools.scene.appendChild(ToolRow("NEBULA", nebulaEl));
240
241new PostProcessControl({
242    tools: stageTools,
243    renderer: () => previewStage.renderer,
244    source: CurrentSofSource,
245    row: ToolRow,
246    memory: hubStage
247});
248stageTools.scene.appendChild(ToolRow("BACKGROUND", backgroundEl));
249
250new SceneReflectionControl({ tools: stageTools, renderer: () => previewStage.renderer, memory: hubStage });
251
252nebulaCatalogue.List().then(list =>
253{
254    if (!list.length)
255    {
256        nebulaEl.closest(".dna-tool")?.remove();
257
258        return;
259    }
260
261    const random = document.createElement("option");
262
263    random.value = "";
264    random.textContent = "RANDOM";
265    nebulaEl.appendChild(random);
266
267    for (const nebula of list)
268    {
269        const option = document.createElement("option");
270
271        option.value = nebula.path;
272        option.textContent = nebula.name;
273        nebulaEl.appendChild(option);
274    }
275
276    nebulaPaths = list.map(nebula => nebula.path).filter(Boolean);
277
278    hubStage.Bind(nebulaEl, "nebula");
279});
280
281const previewStage = new DnaStage({
282    stage: document.getElementById("skinr-stage"),
283    status: document.getElementById("skinr-stage-status"),
284    tools: stageTools.element,
285
286    squareUp: "always",
287
288    pose: { rotationX: 5.9, rotationY: 0.45 },
289
290    environment: false,
291
292    clearColour: [ 0.001518, 0.001518, 0.001518, 1 ],
293
294    fitMargin: 1.02
295});
296
297const annotations = new ShipAnnotations({
298    host: () => document.getElementById("skinr-stage"),
299    renderer: () => previewStage.renderer
300});
301
302new BannerControl({
303    tools: stageTools,
304    renderer: () => previewStage.renderer,
305    memory: hubStage,
306    onHover: on => annotations.Banners(on)
307});
308
309new KillmarkControl({
310    tools: stageTools,
311    renderer: () => previewStage.renderer,
312    memory: hubStage,
313    onHover: on => annotations.Killmarks(on)
314});
315
316const cameraAngle = new CameraAngleControl({ stage: previewStage, storageKey: "skindr:hub:angle" });
317
318stageTools.camera.appendChild(cameraAngle.row);
319
320const hullNames = new Map();
321
322const identities = new Map();
323
324const creatorListings = new Map();
325
326const filters = { currency: "", state: "", availability: "all" };
327
328const PERSONAL = new Set([ "own", "mine", "corp", "alliance" ]);
329
330function Availability()
331{
332    if (!Me.SignedIn()) return "market";
333
334    const chosen = filters.availability;
335
336    const held = chosen === "own" ? Owned() : personal[chosen];
337
338    return held === 0 ? "all" : chosen;
339}
340
341const personal = { own: null, mine: null, corp: null, alliance: null };
342
343let licences = [];
344
345const EMPTY = Object.freeze({
346    own: "NOTHING OF YOURS YET",
347    mine: "YOU HAVE NOTHING LISTED",
348    corp: "NOTHING AIMED AT YOUR CORP",
349    alliance: "NOTHING AIMED AT YOUR ALLIANCE"
350});
351
352function ShowAvailability()
353{
354    const signedIn = Me.SignedIn();
355    const buttons = [ ...filtersEl.querySelectorAll('button[data-filter="availability"]') ];
356
357    for (const button of buttons)
358    {
359        const value = button.dataset.value;
360
361        if (value === "market") button.disabled = false;
362        else if (!signedIn) button.disabled = true;
363        else if (value === "all") button.disabled = false;
364        else
365        {
366            const held = value === "own" ? Owned() : personal[value];
367
368            button.disabled = held === 0;
369        }
370    }
371
372    if (!Me.Known()) return;
373
374    const live = buttons.filter(button => !button.disabled);
375
376    if (live.length === 1 && filters.availability !== live[0].dataset.value)
377    {
378        filters.availability = live[0].dataset.value;
379        ApplyFilterButtons();
380    }
381}
382
383const WARMING_ATTEMPTS = 5;
384const WARMING_WAIT_MS = 2000;
385
386async function Availabilities()
387{
388    let answer = null;
389
390    for (let attempt = 0; attempt < WARMING_ATTEMPTS; attempt++)
391    {
392        const next = await Json("/api/me/hub/availability").catch(() => null);
393
394        if (next?.state === "ok") answer = next;
395
396        if (answer && !Object.values(answer.counts).some(count => count === null)) return answer;
397
398        await new Promise(done => setTimeout(done, WARMING_WAIT_MS));
399    }
400
401    return answer;
402}
403
404function Owned()
405{
406    if (personal.own === null) return null;
407
408    return personal.own + licences.length;
409}
410
411let shuffleSeed = 0;
412
413let generation = 0;
414let detailGeneration = 0;
415let facetsLoaded = false;
416let plexRate = null;
417let rows = [];
418
419let selectedEntry = null;
420
421let view = Remember("skinr", {
422
423    search: "",
424    mode: MODES[0].name,
425    showPrice: true,
426    showHull: true,
427    previewWidth: 0,
428
429    sort: "random",
430    descending: false,
431    group: "none",
432    tier: "",
433    currency: "",
434    state: "",
435    availability: "all"
436});
437
438const viewPanelEl = document.getElementById("catalog-view-menu");
439
440if (view.sort === "price-desc")
441{
442    view.sort = "price";
443    view.descending = true;
444}
445
446function ApplyView(redraw = true)
447{
448    const shared = ViewSettings.Get();
449
450    ViewSettings.ApplyTo(paneEl);
451    grid.overlays = { tag: shared.showTag };
452    grid.showName = shared.showName;
453
454    paneEl.classList.toggle("no-colour", !shared.showColour);
455
456    paneEl.classList.toggle("no-price", !view.showPrice);
457
458    if (redraw) grid.Redraw();
459    else grid.EnsureScrollable();
460}
461
462ViewSettings.Subscribe(() => ApplyView(true));
463
464for (const [ id, key ] of Object.entries({ "view-show-price": "showPrice", "view-show-hull": "showHull" }))
465{
466    const input = document.getElementById(id);
467
468    if (!input) continue;
469
470    input.checked = Boolean(view[key]);
471    input.addEventListener("input", () =>
472    {
473        view[key] = input.checked;
474
475        ApplyView(key === "showHull");
476    });
477}
478
479const groupDropdown = new Dropdown({ onChange: () =>
480{
481    grid.GroupBy(groupDropdown.Value() || "none");
482
483    RememberRibbon();
484} });
485const tierDropdown = new Dropdown({ onChange: () => Load() });
486const sortDropdown = new Dropdown({ onChange: () =>
487{
488
489    view.descending = SORT_DESCENDS[sortDropdown.Value()] ?? false;
490    ApplyDirection();
491    Load();
492} });
493
494const grid = new ComponentGrid({
495    sort: "none",
496    groups: GROUPS,
497    onSelect: (entry, card) => Select(entry, card),
498    card: entry => new SkinrCardElement(entry, {
499        hullName: HullName,
500        tag: ViewSettings.Get().showTag,
501        name: ViewSettings.Get().showName,
502
503        price: true,
504        hull: view.showHull
505    })
506});
507
508const listWindowEl = document.createElement("div");
509const listEl = document.createElement("table");
510
511listWindowEl.className = "skinr-list-window";
512listEl.className = "skinr-list";
513listEl.innerHTML = `
514    <colgroup>
515        <col class="skinr-col-name"><col class="skinr-col-line"><col class="skinr-col-hull">
516        <col class="skinr-col-tier"><col class="skinr-col-price"><col class="skinr-col-when">
517    </colgroup>
518    <thead><tr>
519        <th scope="col">SKIN</th><th scope="col">LINE</th><th scope="col">HULL</th>
520        <th scope="col">TIER</th><th scope="col">PRICE</th><th scope="col">LISTED</th>
521    </tr></thead>
522    <tbody></tbody>`;
523listWindowEl.appendChild(listEl);
524
525const footName = new TextScramble(nameEl, { glitch: true });
526
527const detailLayer = UiLayers.Register({
528    isOpen: () => detailEl.classList.contains("open"),
529    close: CloseDetail
530});
531
532gridHostEl.replaceWith(grid.element);
533document.getElementById("skinr-group").appendChild(groupDropdown.element);
534document.getElementById("skinr-tier").appendChild(tierDropdown.element);
535document.getElementById("skinr-sort").appendChild(sortDropdown.element);
536
537groupDropdown.Show([
538    { value: "none", label: "NONE" },
539    { value: "creator", label: "CREATOR" },
540    { value: "seller", label: "SELLER" },
541    { value: "ship", label: "SHIP" },
542    { value: "category", label: "SHIP CLASS" },
543    { value: "tier", label: "TIER" },
544    { value: "currency", label: "CURRENCY" },
545    { value: "state", label: "STATE" }
546], view.group);
547tierDropdown.Show([ { value: "", label: "ANY" } ], view.tier);
548
549sortDropdown.Show([
550
551    { value: "random", label: "RANDOM" },
552    { value: "recent", label: "LISTED" },
553    { value: "price", label: "PRICE" },
554    { value: "tier", label: "TIER" },
555    { value: "name", label: "NAME" },
556    { value: "hull", label: "HULL" }
557], view.sort);
558
559grid.GroupBy(view.group || "none");
560
561const directionEl = document.getElementById("skinr-sort-direction");
562
563function ApplyDirection()
564{
565
566    const shuffling = sortDropdown.Value() === "random";
567
568    directionEl.classList.toggle("shuffling", shuffling);
569    directionEl.disabled = false;
570
571    if (shuffling)
572    {
573
574        directionEl.removeAttribute("aria-pressed");
575        directionEl.title = "Shuffle again";
576        directionEl.setAttribute("aria-label", "Shuffle again");
577
578        return;
579    }
580
581    directionEl.setAttribute("aria-pressed", view.descending ? "true" : "false");
582    directionEl.title = view.descending ? "Descending" : "Ascending";
583    directionEl.setAttribute("aria-label", "Sort direction");
584}
585
586directionEl.addEventListener("click", () =>
587{
588
589    if (sortDropdown.Value() !== "random") view.descending = !view.descending;
590
591    ApplyDirection();
592    Load();
593});
594
595ApplyDirection();
596
597const skinrMenu = new RibbonMenu({ ribbon: filtersEl, label: "Paragon Hub settings", id: "skinr-ribbon-menu" });
598
599PlexPrice.OnChange(() =>
600{
601    rows = rows.map(entry => ({ ...entry, priceLines: PriceLines(entry.price) }));
602    Draw();
603
604    if (selectedEntry) ShowPrices(selectedEntry, latestCount, latestCost(selectedEntry.skinrId));
605});
606
607filters.currency = view.currency;
608filters.state = view.state;
609filters.availability = view.availability ?? "all";
610
611searchEl.value = view.search ?? "";
612ApplyFilterButtons();
613ShowAvailability();
614
615Me.Load().then(async () =>
616{
617    ShowAvailability();
618    ApplyFilterButtons();
619
620    if (filters.availability !== "market") Load();
621
622    if (!Me.SignedIn()) return;
623
624    const [ counts ] = await Promise.all([
625        Availabilities(),
626
627        Ownership.LoadDesigns()
628    ]);
629
630    licences = Ownership.Designs();
631
632    if (counts?.state === "ok") Object.assign(personal, counts.counts);
633
634    const before = filters.availability;
635
636    ShowAvailability();
637
638    if (filters.availability !== before) Load();
639});
640
641ApplyMode();
642
643async function Build()
644{
645    if (sdeBuild) return sdeBuild;
646
647    const { target } = CurrentSofSource();
648
649    try
650    {
651        const response = await fetch(`/${target}/latest/build`);
652        const document_ = response.ok ? await response.json() : null;
653
654        sdeBuild = document_?.builds?.sde ?? "latest";
655    }
656    catch
657    {
658        sdeBuild = "latest";
659    }
660
661    return sdeBuild;
662}
663
664function HullName(typeID)
665{
666    if (!typeID) return Promise.resolve(null);
667    if (hullNames.has(typeID)) return hullNames.get(typeID);
668
669    const pending = Build()
670        .then(build => Json(`/${CurrentSofSource().target}/${build}/types/${typeID}?lang=en`))
671        .then(type => ({
672            name: type?.name?.text ?? `TYPE ${typeID}`,
673            group: type?.groupName?.text ?? null,
674
675            category: type?.groupName?.text ?? null
676        }))
677        .catch(() => ({ name: `TYPE ${typeID}`, group: null, category: null }));
678
679    hullNames.set(typeID, pending);
680
681    return pending;
682}
683
684async function PlexRate()
685{
686    try
687    {
688        const answer = await Json("/v1/market/plex");
689
690        plexRate = Number.isFinite(Number(answer?.isk)) ? answer : null;
691        PlexPrice.SetMarket(plexRate);
692    }
693    catch
694    {
695        plexRate = null;
696        PlexPrice.SetMarket(null);
697    }
698}
699
700const Grouped = value => Number(value).toLocaleString("en-US", { maximumFractionDigits: 0 });
701
702function CompactNumber(value)
703{
704
705    const Short = (scaled, unit) =>
706        `${scaled >= 10 ? Math.round(scaled) : Math.round(scaled * 10) / 10}${unit}`;
707
708    if (value >= 1e15) return Short(value / 1e15, "Q");
709    if (value >= 1e12) return Short(value / 1e12, "T");
710    if (value >= 1e9) return Short(value / 1e9, "B");
711    if (value >= 1e6) return Short(value / 1e6, "M");
712
713    return Grouped(value);
714}
715
716function CompactIsk(value)
717{
718    return CompactNumber(value);
719}
720
721function PriceText(price)
722{
723    if (!price || price.value === null || price.value === undefined) return "";
724
725    const value = Number(price.value);
726
727    return price.kind === "plex" ? `${Grouped(value)} PLEX` : `${CompactIsk(value)} ISK`;
728}
729
730function Query(withoutSearch = false)
731{
732    const sort = sortDropdown.Value() ?? "random";
733    const query = new URLSearchParams({ limit: String(PAGE_SIZE), sort });
734
735    if (sort === "random") query.set("seed", String(shuffleSeed));
736
737    if (sort !== "random") query.set("direction", view.descending ? "desc" : "asc");
738
739    if (!withoutSearch && searchEl.value.trim()) query.set("search", searchEl.value.trim());
740    if (tierDropdown.Value()) query.set("tier", tierDropdown.Value());
741    if (filters.currency) query.set("currency", filters.currency);
742    if (filters.state) query.set("state", filters.state);
743
744    if (!filters.currency && String(sortDropdown.Value()).startsWith("price")) query.set("currency", "isk");
745
746    return query;
747}
748
749function ApplyFilterButtons()
750{
751    for (const button of filtersEl.querySelectorAll("button[data-filter]"))
752    {
753
754        const current = button.dataset.filter === "availability"
755            ? Availability()
756            : filters[button.dataset.filter] ?? "";
757
758        button.classList.toggle("active", current === button.dataset.value);
759    }
760}
761
762function RememberRibbon()
763{
764    view.sort = sortDropdown.Value() ?? "random";
765    view.descending = Boolean(view.descending);
766    view.group = groupDropdown.Value() ?? "none";
767    view.tier = tierDropdown.Value() ?? "";
768    view.currency = filters.currency;
769    view.state = filters.state;
770    view.availability = filters.availability;
771
772    view.search = searchEl.value;
773}
774
775filtersEl.addEventListener("click", event =>
776{
777    const button = event.target.closest("button[data-filter]");
778
779    if (!button || button.disabled) return;
780
781    filters[button.dataset.filter] = button.dataset.value;
782
783    for (const sibling of button.parentElement.querySelectorAll("button"))
784    {
785        sibling.classList.toggle("active", sibling === button);
786    }
787
788    Load();
789});
790
791let debounce;
792searchEl.addEventListener("input", () =>
793{
794    clearTimeout(debounce);
795    debounce = setTimeout(Load, 200);
796});
797
798AttachSearchClear(searchEl, () => { clearTimeout(debounce); Load(); });
799
800modeEl?.addEventListener("click", () =>
801{
802    const at = MODES.findIndex(mode => mode.name === view.mode);
803
804    view.mode = MODES[(at + 1) % MODES.length].name;
805    ApplyMode();
806    Draw();
807});
808
809function ApplyMode()
810{
811    const current = MODES.find(mode => mode.name === view.mode) ?? MODES[0];
812
813    view.mode = current.name;
814    if (modeEl)
815    {
816        modeEl.title = current.title;
817        modeEl.setAttribute("aria-label", current.title);
818    }
819
820    for (const icon of modeEl?.querySelectorAll("[data-mode-icon]") ?? [])
821    {
822        icon.hidden = icon.dataset.modeIcon !== current.name;
823    }
824
825    columnsEl.replaceChildren(current.name === "icons" ? grid.element : listWindowEl);
826}
827
828const hullFacts = new Map();
829
830const RACES = Object.freeze({
831    1: "Caldari",
832    2: "Minmatar",
833    4: "Amarr",
834    8: "Gallente",
835    16: "Jove",
836    32: "Society of Conscious Thought"
837});
838
839let hullIndexBuilt = false;
840
841let hullIndexReady = Promise.resolve();
842
843function HullFacts(typeID)
844{
845    if (!typeID) return Promise.resolve(null);
846    if (hullFacts.has(typeID)) return Promise.resolve(hullFacts.get(typeID));
847
848    const pending = Build()
849        .then(build => Json(`/${CurrentSofSource().target}/${build}/types/${typeID}`))
850        .then(type => ({
851            name: type?.name?.text ?? "",
852            race: RACES[type?.raceID] ?? "",
853            group: type?.groupName?.text ?? ""
854        }))
855        .catch(() => ({ name: "", race: "", group: "" }))
856        .then((facts) =>
857        {
858            hullFacts.set(typeID, facts);
859
860            return facts;
861        });
862
863    hullFacts.set(typeID, pending);
864
865    return pending;
866}
867
868function BuildHullIndex(hulls)
869{
870    if (hullIndexBuilt) return hullIndexReady;
871
872    hullIndexBuilt = true;
873
874    const queue = hulls.map(hull => hull.shipTypeId).filter(Boolean);
875
876    const worker = async () =>
877    {
878        while (queue.length)
879        {
880            await HullFacts(queue.shift());
881        }
882    };
883
884    hullIndexReady = Promise.all(Array.from({ length: 8 }, worker)).then(() =>
885    {
886
887        if (searchEl.value.trim()) Load();
888    });
889
890    return hullIndexReady;
891}
892
893function MatchesText(entry, text)
894{
895    return [
896        entry.name,
897        entry.line,
898        entry.creatorName,
899        entry.sellerName
900    ].some(value => String(value ?? "").toLowerCase().includes(text))
901        || MatchesHull(entry, text);
902}
903
904function MatchesHull(entry, text)
905{
906    const facts = hullFacts.get(entry.typeID);
907
908    if (!facts || typeof facts.then === "function") return false;
909
910    return `${facts.name} ${facts.race} ${facts.group}`.toLowerCase().includes(text);
911}
912
913async function Load()
914{
915    const mine = ++generation;
916
917    Busy(grid.element, true);
918
919    if (!SkinrAvailable())
920    {
921        Busy(grid.element, false);
922        grid.Show([]);
923        statusEl.textContent = "TRANQUILITY ONLY";
924
925        return;
926    }
927
928    shuffleSeed = Math.trunc(Math.random() * 0x7fffffff);
929    RememberRibbon();
930
931    statusEl.textContent = "LOADING";
932
933    const availability = Availability();
934
935    if (PERSONAL.has(availability))
936    {
937        await ShowPersonal(availability, mine);
938
939        return;
940    }
941
942    try
943    {
944        const text = searchEl.value.trim().toLowerCase();
945
946        const page = await Json(`/v1/skinr/cards?${Query(Boolean(text))}`);
947
948        if (mine !== generation) return;
949
950        let cards = page.cards;
951
952        const key = sortDropdown.Value();
953        const whole = text || key === "hull" || groupDropdown.Value() === "ship";
954
955        if (whole)
956        {
957            while (cards.length < page.total && cards.length < PAGE_SIZE * 4)
958            {
959                const next = await Json(`/v1/skinr/cards?${Query(true)}&offset=${cards.length}`);
960
961                if (mine !== generation) return;
962                if (!next.cards?.length) break;
963
964                cards = cards.concat(next.cards);
965            }
966        }
967
968        rows = cards.map(RowOf);
969
970        if (text)
971        {
972            const named = new Set((await Json(`/v1/skinr/cards?${Query()}`)).cards.map(card => card.listingId));
973
974            await hullIndexReady;
975
976            if (mine !== generation) return;
977
978            rows = rows.filter(entry => named.has(entry.listingId) || MatchesHull(entry, text));
979        }
980
981        if (key === "hull" || groupDropdown.Value() === "ship") await hullIndexReady;
982
983        if (mine !== generation) return;
984
985        if (sortDropdown.Value() === "hull")
986        {
987            rows = ByHull(rows);
988            if (view.descending) rows.reverse();
989        }
990
991        Draw();
992
993        const matched = text ? rows.length : page.total;
994
995        statusEl.textContent = matched
996            ? `${rows.length.toLocaleString()} SHOWN · ${matched.toLocaleString()} MATCH`
997            : "NO MATCHES";
998    }
999    catch (error)
1000    {
1001        if (mine !== generation) return;
1002
1003        rows = [];
1004        Draw();
1005
1006        Busy(grid.element, false);
1007        statusEl.textContent = /501/u.test(error.message) ? "NO HARVEST YET" : UNRESPONSIVE;
1008    }
1009}
1010
1011function PriceLines(price)
1012{
1013    const value = price ? Number(price.value) : null;
1014
1015    if (!price || !Number.isFinite(value)) return [];
1016
1017    const rate = Number(PlexPrice.Rate());
1018    const rateKnown = Number.isFinite(rate) && rate > 0;
1019    const listedPlex = price.kind === "plex";
1020
1021    return [ {
1022        kind: listedPlex ? "plex" : "isk",
1023        icon: listedPlex ? EveTypeIconURL(PLEX_TYPE_ID, { size: 32 }) : null,
1024        text: listedPlex ? CompactNumber(value) : CompactIsk(value),
1025
1026        tip: rateKnown
1027
1028            ? `≈ ${listedPlex
1029                ? `${CompactIsk(value * rate)} ISK`
1030                : `${value / rate < 1 ? "<1" : CompactNumber(Math.round(value / rate))} PLEX`}`
1031            : null
1032    } ];
1033}
1034
1035async function ShowPersonal(target, mine)
1036{
1037    try
1038    {
1039
1040        const [ answer ] = await Promise.all([
1041            Json(`/api/me/hub/listings?target=${target}`),
1042            target === "own" ? Ownership.LoadDesigns() : null
1043        ]);
1044
1045        if (mine !== generation) return;
1046
1047        licences = Ownership.Designs();
1048
1049        if (answer.state !== "ok")
1050        {
1051            rows = [];
1052            Draw();
1053
1054            statusEl.textContent = answer.state === "warming"
1055                ? "ASKING EVE — TRY AGAIN"
1056
1057                : answer.state === "unauthorised" ? "SIGN IN TO SEE THESE" : UNRESPONSIVE;
1058
1059            return;
1060        }
1061
1062        personal[target] = answer.listings.length;
1063        ShowAvailability();
1064
1065        const all = target === "own"
1066            ? [ ...licences.map(CardOfLicence), ...answer.listings.map(CardOfListing) ].map(RowOf)
1067            : answer.listings.map(CardOfListing).map(RowOf);
1068
1069        await Icons(all);
1070
1071        if (mine !== generation) return;
1072
1073        const text = searchEl.value.trim().toLowerCase();
1074
1075        if (text) await hullIndexReady;
1076
1077        if (mine !== generation) return;
1078
1079        rows = text ? all.filter(entry => MatchesText(entry, text)) : all;
1080
1081        Draw();
1082
1083        statusEl.textContent = rows.length
1084            ? `${rows.length.toLocaleString()} SHOWN${text ? ` OF ${all.length.toLocaleString()}` : ""}`
1085            : text
1086                ? "NO MATCHES"
1087                : EMPTY[target] ?? "NOTHING AIMED AT YOU";
1088    }
1089    catch
1090    {
1091        if (mine !== generation) return;
1092
1093        rows = [];
1094        Draw();
1095        statusEl.textContent = UNRESPONSIVE;
1096    }
1097}
1098
1099async function Icons(rows)
1100{
1101    const ids = [ ...new Set(rows.map(row => row.skinrId).filter(Boolean)) ];
1102
1103    if (!ids.length) return;
1104
1105    try
1106    {
1107        const answer = await Json(`/api/skinr/icons?ids=${ids.map(encodeURIComponent).join(",")}`);
1108        const urls = new Map((answer?.icons ?? []).map(icon => [ icon.skinrID, icon ]));
1109
1110        for (const row of rows)
1111        {
1112            const icon = urls.get(row.skinrId);
1113
1114            if (icon?.url) row.iconURL = icon.url;
1115        }
1116    }
1117    catch
1118    {
1119
1120    }
1121}
1122
1123function CardOfLicence(licence)
1124{
1125    const design = licence.design ?? {};
1126
1127    return {
1128        listingId: `licence:${licence.skinrID}`,
1129        skinrId: licence.skinrID,
1130        shipTypeId: design.shipTypeID ?? null,
1131
1132        ...(design.name ? { name: design.name } : {}),
1133        line: design.line ?? "",
1134        tierLevel: design.tierLevel ?? null,
1135        creatorId: design.creatorID ?? null,
1136        creatorName: design.creatorName ?? null,
1137        quantity: licence.unactivated,
1138
1139        state: null,
1140        price: null
1141    };
1142}
1143
1144function CardOfListing(listing)
1145{
1146
1147    const price = listing.price?.plex !== null && listing.price?.plex !== undefined
1148        ? { kind: "plex", value: listing.price.plex }
1149        : listing.price?.isk !== null && listing.price?.isk !== undefined
1150            ? { kind: "isk", value: listing.price.isk }
1151            : null;
1152
1153    const design = listing.design ?? {};
1154
1155    return {
1156        listingId: listing.id,
1157        skinrId: listing.skinrID,
1158        sellerId: listing.sellerID,
1159        quantity: listing.quantity,
1160        state: listing.state,
1161        created: listing.created,
1162        expires: listing.expires,
1163        shipTypeId: design.shipTypeID ?? null,
1164        ...(design.name ? { name: design.name } : {}),
1165        line: design.line ?? "",
1166        tierLevel: design.tierLevel ?? null,
1167        creatorId: design.creatorID ?? null,
1168
1169        creatorName: design.creatorName ?? null,
1170        sellerName: listing.sellerName ?? null,
1171        price
1172    };
1173}
1174
1175function RowOf(card)
1176{
1177    return {
1178        listingId: card.listingId,
1179        skinrId: card.skinrId,
1180        typeID: card.shipTypeId ?? null,
1181
1182        harvested: typeof card.name === "string",
1183        name: card.name ?? "NOT HARVESTED",
1184        line: card.line ?? "",
1185
1186        hull: "",
1187        tierLevel: card.tierLevel ?? null,
1188        priceText: PriceText(card.price),
1189        priceLines: PriceLines(card.price),
1190        price: card.price ?? null,
1191        creatorId: card.creatorId ?? null,
1192        sellerId: card.sellerId ?? null,
1193
1194        creatorName: card.creatorName ?? null,
1195        sellerName: card.sellerName ?? null,
1196        quantity: card.quantity ?? null,
1197        state: card.state ?? null,
1198        created: card.created ?? null,
1199        expires: card.expires ?? null,
1200        observedAt: card.observedAt ?? null
1201    };
1202}
1203
1204function ByHull(list)
1205{
1206    return [ ...list ].sort((left, right) => NamedHull(left).localeCompare(NamedHull(right))
1207        || (left.name ?? "").localeCompare(right.name ?? ""));
1208}
1209
1210function NamedHull(entry)
1211{
1212    const held = hullNames.get(entry.typeID);
1213    const named = held && typeof held.then !== "function" ? held.name : null;
1214    const facts = hullFacts.get(entry.typeID);
1215    const indexed = facts && typeof facts.then !== "function" ? facts.name : null;
1216
1217    return named || entry.hull || indexed || `TYPE ${entry.typeID ?? ""}`;
1218}
1219
1220function HullHeading(entry)
1221{
1222    return (NamedHull(entry) || "UNKNOWN HULL").toUpperCase();
1223}
1224
1225function Draw()
1226{
1227    if (view.mode === "icons")
1228    {
1229        Busy(grid.element, false);
1230        grid.Show(rows);
1231
1232        return;
1233    }
1234
1235    const body = listEl.querySelector("tbody");
1236
1237    body.replaceChildren();
1238
1239    for (const entry of rows)
1240    {
1241        const tr = document.createElement("tr");
1242
1243        tr.className = "skinr-row";
1244        tr.tabIndex = 0;
1245        tr.dataset.listingId = entry.listingId;
1246
1247        for (const text of [
1248            entry.name,
1249            entry.line || "—",
1250
1251            NamedHull(entry),
1252            entry.tierLevel ?? "—",
1253            entry.priceText || "—",
1254            entry.created ? entry.created.slice(0, 10) : "—"
1255        ])
1256        {
1257            const td = document.createElement("td");
1258
1259            td.textContent = String(text);
1260            td.title = String(text);
1261            tr.appendChild(td);
1262        }
1263
1264        tr.addEventListener("click", () => Select(entry, tr));
1265        tr.addEventListener("keydown", event =>
1266        {
1267            if (event.key !== "Enter" && event.key !== " ") return;
1268
1269            event.preventDefault();
1270            Select(entry, tr);
1271        });
1272
1273        body.appendChild(tr);
1274
1275        if (entry.typeID)
1276        {
1277            HullName(entry.typeID).then(named =>
1278            {
1279                if (!named) return;
1280
1281                entry.hull = named.name;
1282                entry.hullGroup = named.group ?? null;
1283                entry.category = named.category ?? null;
1284                tr.children[2].textContent = entry.hull;
1285                tr.children[2].title = entry.hull;
1286            });
1287        }
1288    }
1289}
1290
1291async function CountForDesign(entry)
1292{
1293    if (!entry.creatorId || !entry.skinrId)
1294    {
1295        return {
1296            value: null,
1297            note: "Not counted: this listing's design is not harvested, so it has no creator to count by."
1298        };
1299    }
1300
1301    if (!creatorListings.has(entry.creatorId))
1302    {
1303        creatorListings.set(entry.creatorId, Json(
1304            `/v1/skinr/cards?limit=1000&creatorId=${entry.creatorId}`
1305        ).catch(() => null));
1306    }
1307
1308    const page = await creatorListings.get(entry.creatorId);
1309
1310    if (!page) return { value: null, note: "Not counted: the creator's listings could not be read." };
1311
1312    if (page.total > page.cards.length)
1313    {
1314        return {
1315            value: null,
1316            note: `Not counted: this creator has ${page.total.toLocaleStr
1316ing()} listings,`
1317                + " more than one page carries."
1318        };
1319    }
1320
1321    return {
1322        value: page.cards.filter(card => card.skinrId === entry.skinrId).length,
1323        note: "Listings advertising this design, counted across this creator's listings."
1324    };
1325}
1326
1327function PriceCells(entry, count, cost)
1328{
1329    const price = entry.price;
1330    const kind = price?.kind ?? null;
1331    const value = price ? Number(price.value) : null;
1332    const rate = Number(PlexPrice.Rate());
1333    const rateKnown = Number.isFinite(rate) && rate > 0;
1334    const isk = kind === "isk"
1335        ? { text: Grouped(value), estimate: false }
1336        : kind === "plex" && rateKnown
1337
1338            ? { text: `≈ ${Grouped(value * rate)}`, estimate: true }
1339            : { text: "—", estimate: false };
1340
1341    const plex = kind === "plex"
1342        ? { text: Grouped(value), estimate: false }
1343        : kind === "isk" && rateKnown
1344            ? { text: `≈ ${Grouped(value / rate)}`, estimate: true }
1345            : { text: "—", estimate: false };
1346
1347    const iskNote = isk.estimate ? `≈ ${isk.text.replace(/^≈ /u, "")} ISK at the current rate.` : "";
1348    const plexNote = plex.estimate ? `≈ ${plex.text.replace(/^≈ /u, "")} PLEX at the current rate.` : "";
1349
1350    return [
1351        {
1352            kind: "count",
1353            label: "LISTINGS",
1354            text: count?.value === null || count === null ? "—" : Grouped(count.value),
1355            estimate: false,
1356            title: count?.note ?? "Counting"
1357        },
1358        {
1359            kind: "count",
1360
1361            label: "QTY",
1362            size: "qty",
1363            text: entry.quantity === null || entry.quantity === undefined ? "—" : Grouped(entry.quantity),
1364            estimate: false,
1365            title: "How many of this design are in this listing."
1366        },
1367
1368        { kind: "price", size: "plex", label: "PLEX", icon: EveTypeIconURL(PLEX_TYPE_ID, { size: 32 }), ...plex },
1369        { kind: "price", label: "ISK", ...isk },
1370        CostCell(cost, isk.text.replace(/^≈\s*/u, ""))
1371    ];
1372}
1373
1374function CostCell(cost, waitingAt)
1375{
1376    const cell = { kind: "price", label: "EST. BUILD" };
1377
1378    const rate = Number(PlexPrice.Rate());
1379    const Build = answer =>
1380    {
1381        if (!answer || answer.state !== "priced") return null;
1382        if (!answer.sequencing?.plex) return null;
1383        if (!Number.isFinite(rate) || rate <= 0) return null;
1384
1385        return answer.total + answer.sequencing.plex * rate;
1386    };
1387
1388    if (!cost)
1389    {
1390        return {
1391            ...cell,
1392            text: waitingAt || "0.00",
1393            estimate: false,
1394            pending: true
1395        };
1396    }
1397
1398    const build = Build(cost);
1399
1400    if (build !== null)
1401    {
1402        return {
1403            ...cell,
1404            text: Grouped(build),
1405
1406            estimate: true,
1407            cost
1408        };
1409    }
1410
1411    if (cost.state === "priced")
1412    {
1413        return {
1414            ...cell,
1415            text: Grouped(cost.total),
1416            estimate: true,
1417            cost
1418        };
1419    }
1420
1421    const unpriced = cost.parts?.filter(part => part.state === "unpriced").length ?? 0;
1422    const failed = cost.parts?.filter(part => part.state === "error").length ?? 0;
1423    const total = cost.parts?.length ?? 0;
1424
1425    return {
1426        ...cell,
1427        text: "UNKNOWN",
1428        estimate: false,
1429        unknown: true,
1430
1431        title: total === 0
1432            ? "The parts could not be looked up."
1433            : failed
1434                ? `${failed} of ${total} parts could not be looked up, so this cannot be totalled.`
1435                : `${unpriced} of ${total} parts have no sell orders in Jita, so this cannot be totalled.`,
1436        cost
1437    };
1438}
1439
1440const CELL_STAGGER_MS = 70;
1441
1442let priceTimers = [];
1443
1444let latestCount = null;
1445
1446const seenCosts = new Map();
1447
1448function latestCost(designID)
1449{
1450    return designID ? seenCosts.get(designID) ?? null : null;
1451}
1452
1453function ShowPrices(entry, count, cost = null, untilCost = null)
1454{
1455    const stats = document.createElement("dl");
1456    const figures = [];
1457
1458    stats.className = "cat-market-stats";
1459
1460    for (const timer of priceTimers) clearTimeout(timer);
1461    priceTimers = [];
1462
1463    for (const cell of PriceCells(entry, count, cost))
1464    {
1465        const pair = document.createElement("div");
1466        const term = document.createElement("dt");
1467        const detail = document.createElement("dd");
1468
1469        pair.className = `cat-market-pair ${cell.kind}${cell.size ? ` ${cell.size}` : ""}`;
1470
1471        detail.textContent = cell.text;
1472
1473        if (cell.title) detail.title = cell.title;
1474        if (cell.estimate) detail.classList.add("estimate");
1475        if (cell.unknown) detail.classList.add("unknown");
1476        term.textContent = cell.label;
1477
1478        if (cell.cost?.parts?.length)
1479        {
1480            pair.classList.add("has-tip");
1481            pair.appendChild(CostTip(cell.cost));
1482        }
1483
1484        if (cell.icon)
1485        {
1486            const icon = document.createElement("img");
1487
1488            icon.className = "cat-market-icon";
1489            icon.src = cell.icon;
1490            icon.alt = "";
1491
1492            icon.setAttribute("aria-hidden", "true");
1493            term.prepend(icon);
1494        }
1495
1496        pair.append(detail, term);
1497        stats.appendChild(pair);
1498        figures.push({ detail, cell });
1499    }
1500
1501    pricesEl.replaceChildren(stats);
1502
1503    const waiting = Boolean(untilCost) && !cost;
1504
1505    figures.forEach(({ detail, cell }, index) =>
1506    {
1507
1508        if (waiting)
1509        {
1510            const scramble = new TextScramble(detail, { glyphs: NUMERIC_GLYPHS });
1511
1512            const settled = cell.text;
1513
1514            priceTimers.push(setTimeout(() => scramble.Hold(settled, untilCost), index * CELL_STAGGER_MS));
1515
1516            return;
1517        }
1518
1519        const scramble = new TextScramble(detail, { glyphs: NUMERIC_GLYPHS });
1520
1521        priceTimers.push(setTimeout(() => scramble.Play(), index * CELL_STAGGER_MS));
1522    });
1523
1524    const marks = [];
1525
1526    if (Ownership.HasDesign(entry.skinrId)) marks.push(OwnedMark());
1527
1528    if (entry.state)
1529    {
1530        const state = document.createElement("span");
1531
1532        state.className = "skinr-state";
1533        state.textContent = String(entry.state).toUpperCase().replace(/_/gu, " ");
1534        state.tabIndex = 0;
1535        marks.push(state);
1536    }
1537
1538    stateEl.replaceChildren(...marks, DateTip(entry), ShareControl(entry));
1539
1540    PaintCloseControl();
1541}
1542
1543function OwnedMark()
1544{
1545    const owned = document.createElement("span");
1546
1547    owned.className = "skinr-state skinr-state-owned";
1548    owned.textContent = "OWNED";
1549    owned.tabIndex = 0;
1550
1551    return owned;
1552}
1553
1554let closeControl = null;
1555
1556function ShowCloseControl()
1557{
1558
1559    const ribbon = document.getElementById("skinr-filters");
1560
1561    if (!ribbon) return false;
1562
1563    closeControl = closeControl ?? BuildCloseControl();
1564
1565    if (closeControl.parentNode !== ribbon) ribbon.appendChild(closeControl);
1566
1567    PaintCloseControl();
1568
1569    return true;
1570}
1571
1572function PaintCloseControl()
1573{
1574    if (!closeControl) return false;
1575
1576    const open = previewEl.classList.contains("open");
1577
1578    const ready = Boolean(selectedEntry);
1579    const title = !ready
1580        ? "Pick a design to see it painted"
1581        : (open ? "Close the painted view" : "Show the painted view");
1582
1583    closeControl.disabled = !ready;
1584    closeControl.title = title;
1585    closeControl.setAttribute("aria-label", title);
1586    closeControl.setAttribute("aria-expanded", String(open));
1587    closeControl.classList.toggle("collapsed", !open);
1588
1589    return true;
1590}
1591
1592function BuildCloseControl()
1593{
1594    const close = document.createElement("button");
1595
1596    close.type = "button";
1597    close.className = "skinr-state-close skinr-ribbon-fold";
1598
1599    close.innerHTML = '<svg viewBox="0 0 12 12" aria-hidden="true"><path d="M7.5 1.5 3 6l4.5 4.5"/></svg>';
1600
1601    close.addEventListener("click", (event) =>
1602    {
1603
1604        event.stopPropagation();
1605
1606        if (previewEl.classList.contains("open")) ClosePreview();
1607        else OpenPreview();
1608
1609        PaintCloseControl();
1610    });
1611
1612    return close;
1613}
1614
1615ShowCloseControl();
1616
1617CountSpins({ stage: document.getElementById("skinr-stage"), renderer: () => previewStage.renderer });
1618
1619function ShareControl(entry)
1620{
1621    const button = document.createElement("button");
1622
1623    button.type = "button";
1624    button.className = "skinr-share";
1625    button.textContent = "SHARE";
1626
1627    const settings = Settings.Load();
1628
1629    if (!ShareLink(entry, settings)) return document.createDocumentFragment();
1630
1631    button.addEventListener("click", event =>
1632    {
1633        event.stopPropagation();
1634
1635        Share({
1636            link: view => ShareLink(entry, settings, view),
1637            options: [ {
1638                key: "fullscreen",
1639                label: "Fullscreen",
1640                title: "Opens with the picture filling their screen"
1641            } ]
1642        });
1643    });
1644
1645    return button;
1646}
1647
1648async function Select(entry, opener = null)
1649{
1650    if (!entry) return;
1651
1652    const mine = ++detailGeneration;
1653
1654    selectedEntry = entry;
1655    latestCount = null;
1656
1657    detailLayer.Open(opener);
1658    detailEl.classList.add("open");
1659
1660    OpenPreview();
1661
1662    for (const chosen of listEl.querySelectorAll("tr.chosen")) chosen.classList.remove("chosen");
1663    if (opener?.tagName === "TR") opener.classList.add("chosen");
1664
1665    let painted = null;
1666    const untilPainted = new Promise(resolve => { painted = resolve; });
1667
1668    author.until = untilPainted;
1669    ShowTitle(entry, mine, untilPainted);
1670
1671    author.Show(entry);
1672
1673    const design = entry.skinrId;
1674    const pricing = design ? DesignCost(design) : null;
1675
1676    ShowPrices(entry, null, latestCost(design), pricing);
1677
1678    if (pricing)
1679    {
1680        pricing.then(cost =>
1681        {
1682
1683            const answer = cost ?? { state: "error", parts: [] };
1684
1685            seenCosts.set(design, answer);
1686
1687            if (mine !== detailGeneration) return;
1688
1689            ShowPrices(entry, latestCount, answer);
1690        });
1691    }
1692
1693    const count = await CountForDesign(entry);
1694
1695    if (mine !== detailGeneration) return;
1696
1697    latestCount = count;
1698    ShowPrices(entry, count, latestCost(design), pricing);
1699
1700    if (!entry.skinrId)
1701    {
1702        previewStage.Show(null);
1703
1704        return;
1705    }
1706
1707    try
1708    {
1709
1710        const design = await Json(`/api/skinr/designs/${entry.skinrId}`);
1711
1712        if (mine !== detailGeneration) return;
1713
1714        window.skinrSelected = design;
1715
1716        const pose = hubResumePose;
1717
1718        hubResumePose = null;
1719        previewStage.ShowSkinr(design).then(() =>
1720        {
1721            if (pose && mine === detailGeneration) previewStage.renderer?.SetCameraPose?.(pose);
1722
1723            ApplyScene();
1724            painted();
1725        }, painted);
1726    }
1727    catch
1728    {
1729        if (mine === detailGeneration) window.skinrSelected = null;
1730        painted();
1731    }
1732}
1733
1734function DateTip(entry)
1735{
1736    const tip = document.createElement("div");
1737    const heading = document.createElement("strong");
1738
1739    tip.className = "hub-tip skinr-datetip";
1740
1741    heading.textContent = entry.state
1742        ? (entry.state === "listed" ? "ON OFFER" : "NOT ON OFFER")
1743        : "OWNED";
1744    tip.appendChild(heading);
1745
1746    for (const [ label, value ] of [
1747        [ "LISTED", entry.created ],
1748        [ "EXPIRES", entry.expires ],
1749        [ "SEEN", entry.observedAt ]
1750    ].filter(([ , stamp ]) => stamp).map(([ label, stamp ]) => [ label, When(stamp) ]))
1751    {
1752        const term = document.createElement("span");
1753        const figure = document.createElement("span");
1754
1755        term.className = "hub-tip-label";
1756        term.textContent = label;
1757        figure.className = "hub-tip-value";
1758        figure.textContent = value;
1759        tip.append(term, figure);
1760    }
1761
1762    return tip;
1763}
1764
1765function ShowShipIcon(typeID)
1766{
1767    const img = shipIconEl?.querySelector("img");
1768
1769    if (!img) return;
1770
1771    img.hidden = false;
1772    SetImage(img, typeID ? EveTypeIconURL(typeID, { size: 64 }) : null);
1773}
1774
1775function When(stamp)
1776{
1777    return stamp ? stamp.replace("T", " ").slice(0, 16) : "—";
1778}
1779
1780function OpenPreview()
1781{
1782    if (previewEl.classList.contains("open")) return;
1783
1784    previewEl.classList.add("open");
1785    previewStage.Activate();
1786
1787    PaintCloseControl();
1788
1789    const focused = document.activeElement;
1790    const tag = String(focused?.tagName ?? "").toLowerCase();
1791
1792    if (tag === "input" || tag === "textarea") focused.blur?.();
1793}
1794
1795function ClosePreview()
1796{
1797    if (!previewEl.classList.contains("open")) return;
1798
1799    previewEl.classList.remove("open");
1800    previewStage.Deactivate();
1801    PaintCloseControl();
1802}
1803
1804function CloseDetail()
1805{
1806    ++detailGeneration;
1807    selectedEntry = null;
1808    latestCount = null;
1809    detailEl.classList.remove("open");
1810    detailLayer.Closed();
1811    ClosePreview();
1812    PaintCloseControl();
1813
1814    for (const chosen of grid.element.querySelectorAll(".cmp-card.chosen")) chosen.classList.remove("chosen");
1815    for (const chosen of listEl.querySelectorAll("tr.chosen")) chosen.classList.remove("chosen");
1816
1817    footName.Play("");
1818    nameEl.removeAttribute("title");
1819    subEl.textContent = "";
1820    author.Clear();
1821    pricesEl.replaceChildren();
1822    stateEl.replaceChildren();
1823    grid.EnsureScrollable();
1824}
1825
1826async function LoadFacets()
1827{
1828    if (facetsLoaded) return;
1829
1830    facetsLoaded = true;
1831
1832    try
1833    {
1834        const facets = await Json("/v1/skinr/facets");
1835
1836        BuildHullIndex(facets.hulls ?? []);
1837
1838        const tiers = facets.tiers ?? [];
1839        const keepTier = tiers.some(entry => String(entry.tier) === view.tier) ? view.tier : "";
1840
1841        tierDropdown.Show([
1842            { value: "", label: "ANY" },
1843            ...tiers.map(entry => ({
1844                value: String(entry.tier),
1845                label: `${entry.tier} (${entry.n})`
1846            }))
1847        ], keepTier);
1848
1849        for (const [ field, measured, key ] of [
1850            [ "currency", facets.currencies, "currency" ],
1851            [ "state", facets.states, "state" ]
1852        ])
1853        {
1854            for (const button of filtersEl.querySelectorAll(`button[data-filter="${field}"]`))
1855            {
1856                const found = measured?.find(entry => entry[key] === button.dataset.value);
1857
1858                if (found) button.title = `${found.n.toLocaleString()} at this harvest`;
1859            }
1860        }
1861
1862        ApplyFilterButtons();
1863    }
1864    catch
1865    {
1866        facetsLoaded = false;
1867    }
1868}
1869
1870function Identity(characterId)
1871{
1872    const id = Number(characterId);
1873
1874    if (!Number.isFinite(id) || id <= 0) return Promise.resolve(null);
1875    if (identities.has(id)) return identities.get(id);
1876
1877    const pending = fetch(`/v1/identity/characters/${id}`)
1878        .then(response => (response.ok ? response.json() : null))
1879        .catch(() => null);
1880
1881    identities.set(id, pending);
1882
1883    return pending;
1884}
1885
1886async function ShowTitle(entry, mine, untilPainted = null)
1887{
1888    const skin = entry.harvested ? entry.name : "DESIGN NOT HARVESTED";
1889
1890    if (untilPainted) footName.Hold(skin.toUpperCase(), untilPainted);
1891    else footName.Play(skin.toUpperCase());
1892    nameEl.title = entry.harvested ? skin : "This listing's design has not been harvested yet";
1893
1894    subEl.textContent = "";
1895    ShowShipIcon(entry.typeID);
1896
1897    if (!entry.typeID) return;
1898
1899    const named = await HullName(entry.typeID);
1900
1901    if (mine !== detailGeneration || !named) return;
1902
1903    const facts = await HullFacts(entry.typeID);
1904
1905    if (mine !== detailGeneration) return;
1906
1907    const line = [ named.name, named.group, facts?.race ]
1908        .filter(Boolean).join(" / ").toUpperCase();
1909
1910    if (untilPainted) new TextScramble(subEl).Hold(line, untilPainted);
1911    else subEl.textContent = line;
1912}
1913
1914DeclarePane({
1915    name: "skinr",
1916    label: "PARAGON HUB",
1917    feature: "hub",
1918    pane: paneEl,
1919
1920    available: HasParagonHub,
1921    unavailable: () => UnavailableReason("The Paragon Hub"),
1922
1923    catalog: true,
1924    focus: searchEl,
1925    activate: async () =>
1926    {
1927
1928        skinrMenu.Adopt(viewPanelEl);
1929        ViewSettings.ShowSection(viewPanelEl, "skinr");
1930        ApplyView(false);
1931
1932        await PlexRate();
1933        await LoadFacets();
1934
1935        if (rows.length && RestoreHubTab()) return;
1936
1937        await Load();
1938        RestoreHubSelection();
1939    },
1940    deactivate: () =>
1941    {
1942        ++generation;
1943
1944        CaptureHubTab();
1945
1946        CloseDetail();
1947        groupDropdown.Close();
1948        tierDropdown.Close();
1949        sortDropdown.Close();
1950    }
1951});
1952
1953Object.defineProperty(window, "skinrPlexRate", { get: () => plexRate });
1954
1955const previewResizer = new ColumnResizer({
1956    host: paneEl,
1957    column: document.getElementById("skinr-preview"),
1958    variable: "--skinr-preview-column",
1959    width: Number(view.previewWidth) || 0,
1960    onChange: width =>
1961    {
1962        view.previewWidth = width;
1963
1964        previewStage.Reframe?.();
1965    }
1966});
1967
1968const HUB_RESUME_KEY = "skindr:hub:resume";
1969
1970let hubResumePose = null;
1971
1972let hubResumeConsumed = false;
1973
1974let hubTab = null;
1975
1976function ScrollHost()
1977{
1978    const candidates = [ grid.element, listWindowEl, grid.element?.parentElement, paneEl ];
1979
1980    return candidates.find(el => el && el.scrollHeight > el.clientHeight + 1) ?? null;
1981}
1982
1983function CaptureHubTab()
1984{
1985    hubTab = {
1986        listingId: selectedEntry?.listingId ?? null,
1987        skinrId: selectedEntry?.skinrId ?? null,
1988        scrollTop: ScrollHost()?.scrollTop ?? 0,
1989
1990        pose: previewStage.renderer?.ReadCameraPose?.() ?? null
1991    };
1992}
1993
1994function RestoreHubTab()
1995{
1996    const state = hubTab;
1997
1998    hubTab = null;
1999
2000    if (!state) return false;
2001
2002    const found = state.listingId || state.skinrId
2003        ? grid.Reveal(row => (state.listingId && row.listingId === state.listingId)
2004            || (state.skinrId && row.skinrId === state.skinrId))
2005        : null;
2006
2007    if (found)
2008    {
2009
2010        hubResumePose = state.pose ?? null;
2011        Select(found.entry, view.mode === "icons"
2012            ? found.card
2013            : listEl.querySelector(`tr[data-listing-id="${CSS.escape(found.entry.listingId)}"]`));
2014    }
2015
2016    const host = ScrollHost();
2017
2018    if (host) host.scrollTop = state.scrollTop;
2019
2020    return true;
2021}
2022
2023window.addEventListener("pagehide", () =>
2024{
2025    try
2026    {
2027
2028        if (!selectedEntry) sessionStorage.removeItem(HUB_RESUME_KEY);
2029        else sessionStorage.setItem(HUB_RESUME_KEY, JSON.stringify({
2030            listingId: selectedEntry.listingId ?? null,
2031            skinrId: selectedEntry.skinrId ?? null,
2032
2033            pose: previewStage.renderer?.ReadCameraPose?.() ?? null
2034        }));
2035    }
2036    catch {  }
2037});
2038
2039function RestoreHubSelection()
2040{
2041    if (hubResumeConsumed) return;
2042
2043    hubResumeConsumed = true;
2044
2045    let record = null;
2046
2047    try
2048    {
2049        record = JSON.parse(sessionStorage.getItem(HUB_RESUME_KEY) || "null");
2050        sessionStorage.removeItem(HUB_RESUME_KEY);
2051    }
2052    catch {  }
2053
2054    if (!record || selectedEntry) return;
2055
2056    const entry = rows.find(row => row.listingId === record.listingId)
2057        ?? rows.find(row => record.skinrId && row.skinrId === record.skinrId);
2058
2059    if (!entry) return;
2060
2061    hubResumePose = record.pose ?? null;
2062    Select(entry);
2063}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.