Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fca43e64b5 | |||
| bc395a0788 |
@@ -2,7 +2,7 @@
|
|||||||
import type { Character } from '$lib/data/character';
|
import type { Character } from '$lib/data/character';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import type { ClassValue } from 'svelte/elements';
|
import type { ClassValue } from 'svelte/elements';
|
||||||
import { stringsearch } from './stringsearch';
|
import Pick from './Pick.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -15,139 +15,9 @@
|
|||||||
|
|
||||||
let { id, characters, value = $bindable(), class: className, option, required = false }: Props = $props();
|
let { id, characters, value = $bindable(), class: className, option, required = false }: Props = $props();
|
||||||
|
|
||||||
let popover: HTMLElement | undefined = $state();
|
|
||||||
let optionsContainer: HTMLElement | undefined = $state();
|
|
||||||
const expanded = $derived(!popover?.hidden);
|
|
||||||
|
|
||||||
let search = $state('');
|
|
||||||
|
|
||||||
const charas = $derived(characters ?? []);
|
const charas = $derived(characters ?? []);
|
||||||
const searchedCharas = $derived(stringsearch(search, charas, ({ name }) => name) || charas);
|
|
||||||
|
|
||||||
$effect(() => {
|
const key = (v: Character) => ({id: v.chara_id, name: v.name});
|
||||||
if (required && value == null && charas.length > 0) {
|
|
||||||
value = charas[0];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function setByElem(o: HTMLElement) {
|
|
||||||
const j = parseInt(o.dataset.charaId ?? '');
|
|
||||||
value = charas.find((c) => c.chara_id === j) ?? value;
|
|
||||||
o.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
function onkeydown(evt: KeyboardEvent) {
|
|
||||||
switch (evt.key) {
|
|
||||||
case 'ArrowDown': {
|
|
||||||
const opts = [...optionsContainer!.children] as HTMLElement[];
|
|
||||||
const i = opts.findIndex((n) => parseInt(n.dataset.charaId ?? '') === value?.chara_id);
|
|
||||||
const o = i != null && i >= 0 ? opts[Math.min(i + 1, opts.length - 1)] : opts[0];
|
|
||||||
setByElem(o);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'ArrowUp': {
|
|
||||||
const opts = [...optionsContainer!.children] as HTMLElement[];
|
|
||||||
const i = opts.findIndex((n) => parseInt(n.dataset.charaId ?? '') === value?.chara_id);
|
|
||||||
const o = i != null && i > 0 ? opts[i - 1] : opts[0];
|
|
||||||
setByElem(o);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'Home': {
|
|
||||||
search = '';
|
|
||||||
setByElem(optionsContainer!.children[0] as HTMLElement);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'End': {
|
|
||||||
search = '';
|
|
||||||
setByElem(optionsContainer!.children[optionsContainer!.childElementCount - 1] as HTMLElement);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case ' ':
|
|
||||||
popover!.showPopover();
|
|
||||||
break;
|
|
||||||
case 'Escape':
|
|
||||||
// TODO(zeph): restore chara that was selected when the popup opened?
|
|
||||||
// for now just hide
|
|
||||||
popover!.hidePopover();
|
|
||||||
break;
|
|
||||||
case 'Enter':
|
|
||||||
popover!.togglePopover();
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
evt.preventDefault();
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class={className} style={`anchor-name: --anchor-${id}`}>
|
<Pick {id} items={charas} {key} bind:value {option} class={className} {required} />
|
||||||
<span
|
|
||||||
{id}
|
|
||||||
class="block h-9 content-center bg-mist-300 hover:cursor-pointer dark:bg-mist-900"
|
|
||||||
role="combobox"
|
|
||||||
aria-expanded={expanded}
|
|
||||||
aria-controls="charaOptions"
|
|
||||||
aria-haspopup="listbox"
|
|
||||||
onclick={() => popover?.togglePopover()}
|
|
||||||
{onkeydown}
|
|
||||||
tabindex="0">{value?.name ?? ''}</span
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="skill-tip absolute top-2 flex-col px-2 shadow-lg open:flex"
|
|
||||||
style={`position-anchor: --anchor-${id}; position-area: bottom;`}
|
|
||||||
id="charaOptions"
|
|
||||||
role="listbox"
|
|
||||||
popover
|
|
||||||
bind:this={popover}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
class="my-2 min-h-8 rounded-md border pointer-coarse:min-h-12"
|
|
||||||
placeholder=" Search"
|
|
||||||
role="searchbox"
|
|
||||||
bind:value={search}
|
|
||||||
/>
|
|
||||||
<div class="max-h-72 overflow-y-scroll" bind:this={optionsContainer}>
|
|
||||||
{#if !required}
|
|
||||||
<div
|
|
||||||
class="h-8 w-full text-lg hover:cursor-pointer hover:bg-mist-300 hover:dark:bg-mist-900"
|
|
||||||
role="option"
|
|
||||||
aria-selected={value == undefined}
|
|
||||||
tabindex="0"
|
|
||||||
data-chara-id=""
|
|
||||||
onmousedown={() => {
|
|
||||||
value = undefined;
|
|
||||||
search = '';
|
|
||||||
popover!.hidePopover();
|
|
||||||
}}
|
|
||||||
onfocus={() => (value = undefined)}
|
|
||||||
{onkeydown}
|
|
||||||
>
|
|
||||||
<span class="text-sm italic">Reset</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#each searchedCharas as c (c.chara_id)}
|
|
||||||
<div
|
|
||||||
class="h-8 w-full text-lg hover:cursor-pointer hover:bg-mist-300 hover:dark:bg-mist-900"
|
|
||||||
role="option"
|
|
||||||
aria-selected={value?.chara_id === c.chara_id}
|
|
||||||
tabindex="0"
|
|
||||||
data-chara-id={c.chara_id}
|
|
||||||
onmousedown={() => {
|
|
||||||
value = c;
|
|
||||||
popover!.hidePopover();
|
|
||||||
}}
|
|
||||||
onfocus={() => (value = c)}
|
|
||||||
{onkeydown}
|
|
||||||
>
|
|
||||||
{#if option != null}
|
|
||||||
{@render option(c)}
|
|
||||||
{:else}
|
|
||||||
<span>{c.name}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="w-full h-8 text-lg italic">No matches.</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
<script lang="ts" generics="T">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import type { ClassValue } from 'svelte/elements';
|
||||||
|
import { stringsearch } from './stringsearch';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: string;
|
||||||
|
items: T[];
|
||||||
|
key: (t: T) => {id: number, name: string};
|
||||||
|
value: T | undefined;
|
||||||
|
option?: Snippet<[T]>;
|
||||||
|
class?: ClassValue | null;
|
||||||
|
required?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {id, items, key, value = $bindable(), option, class: className, required = false}: Props = $props();
|
||||||
|
|
||||||
|
let popover: HTMLElement | undefined = $state();
|
||||||
|
let optionsContainer: HTMLElement | undefined = $state();
|
||||||
|
const expanded = $derived(!popover?.hidden);
|
||||||
|
|
||||||
|
let search = $state('');
|
||||||
|
|
||||||
|
const its = $derived(items ?? []);
|
||||||
|
const searched = $derived(stringsearch(search, its, (v) => key(v).name) || its);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (required && value == null && its.length > 0) {
|
||||||
|
value = its[0];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function setByElem(o: HTMLElement) {
|
||||||
|
const j = parseInt(o.dataset.pickId ?? '');
|
||||||
|
value = its.find((c) => key(c).id === j) ?? value;
|
||||||
|
o.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onkeydown(evt: KeyboardEvent) {
|
||||||
|
switch (evt.key) {
|
||||||
|
case 'ArrowDown': {
|
||||||
|
const opts = [...optionsContainer!.children] as HTMLElement[];
|
||||||
|
const i = opts.findIndex((n) => value != null && parseInt(n.dataset.pickId ?? '') === key(value).id);
|
||||||
|
const o = i >= 0 ? opts[Math.min(i + 1, opts.length - 1)] : opts[0];
|
||||||
|
setByElem(o);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'ArrowUp': {
|
||||||
|
const opts = [...optionsContainer!.children] as HTMLElement[];
|
||||||
|
const i = opts.findIndex((n) => value != null && parseInt(n.dataset.pickId ?? '') === key(value).id);
|
||||||
|
const o = i > 0 ? opts[i - 1] : opts[0];
|
||||||
|
setByElem(o);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'Home': {
|
||||||
|
search = '';
|
||||||
|
setByElem(optionsContainer!.children[0] as HTMLElement);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'End': {
|
||||||
|
search = '';
|
||||||
|
setByElem(optionsContainer!.children[optionsContainer!.childElementCount - 1] as HTMLElement);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case ' ':
|
||||||
|
popover!.showPopover();
|
||||||
|
break;
|
||||||
|
case 'Escape':
|
||||||
|
// TODO(zeph): restore chara that was selected when the popup opened?
|
||||||
|
// for now just hide
|
||||||
|
popover!.hidePopover();
|
||||||
|
break;
|
||||||
|
case 'Enter':
|
||||||
|
popover!.togglePopover();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
evt.preventDefault();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={className} style={`anchor-name: --anchor-${id}`}>
|
||||||
|
<span
|
||||||
|
{id}
|
||||||
|
class="block h-9 content-center bg-mist-300 hover:cursor-pointer dark:bg-mist-900"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={expanded}
|
||||||
|
aria-controls="charaOptions"
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
onclick={() => popover?.togglePopover()}
|
||||||
|
{onkeydown}
|
||||||
|
tabindex="0">{value != null ? key(value).name : ''}</span
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="skill-tip absolute top-2 flex-col px-2 shadow-lg open:flex"
|
||||||
|
style={`position-anchor: --anchor-${id}; position-area: bottom;`}
|
||||||
|
id="charaOptions"
|
||||||
|
role="listbox"
|
||||||
|
popover
|
||||||
|
bind:this={popover}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
class="my-2 min-h-8 rounded-md border pointer-coarse:min-h-12"
|
||||||
|
placeholder=" Search"
|
||||||
|
role="searchbox"
|
||||||
|
bind:value={search}
|
||||||
|
/>
|
||||||
|
<div class="max-h-72 overflow-y-scroll" bind:this={optionsContainer}>
|
||||||
|
{#if !required}
|
||||||
|
<div
|
||||||
|
class="h-8 w-full text-lg hover:cursor-pointer hover:bg-mist-300 hover:dark:bg-mist-900"
|
||||||
|
role="option"
|
||||||
|
aria-selected={value == undefined}
|
||||||
|
tabindex="0"
|
||||||
|
data-pick-id=""
|
||||||
|
onmousedown={() => {
|
||||||
|
value = undefined;
|
||||||
|
search = '';
|
||||||
|
popover!.hidePopover();
|
||||||
|
}}
|
||||||
|
onfocus={() => (value = undefined)}
|
||||||
|
{onkeydown}
|
||||||
|
>
|
||||||
|
<span class="text-sm italic">Reset</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#each searched as c (key(c).id)}
|
||||||
|
{@const v = value != null ? key(value).id : 0}
|
||||||
|
{@const k = key(c)}
|
||||||
|
<div
|
||||||
|
class="h-8 w-full text-lg hover:cursor-pointer hover:bg-mist-300 hover:dark:bg-mist-900"
|
||||||
|
role="option"
|
||||||
|
aria-selected={v === k.id}
|
||||||
|
tabindex="0"
|
||||||
|
data-pick-id={k.id}
|
||||||
|
onmousedown={() => {
|
||||||
|
value = c;
|
||||||
|
popover!.hidePopover();
|
||||||
|
}}
|
||||||
|
onfocus={() => (value = c)}
|
||||||
|
{onkeydown}
|
||||||
|
>
|
||||||
|
{#if option != null}
|
||||||
|
{@render option(c)}
|
||||||
|
{:else}
|
||||||
|
<span>{k.name}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="w-full h-8 text-lg italic">No matches.</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -144,3 +144,25 @@ export async function sparks(): Promise<Spark[]> {
|
|||||||
const resp = await fetch('/api/global/spark');
|
const resp = await fetch('/api/global/spark');
|
||||||
return resp.json();
|
return resp.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SparkGroup {
|
||||||
|
spark_group: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
type: Type;
|
||||||
|
effects: Effect[][];
|
||||||
|
spark_ids: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function byGroup(sparks: Spark[]): SparkGroup[] {
|
||||||
|
const r = new Map<number, SparkGroup>();
|
||||||
|
for (const {spark_id, name, description, spark_group, type, effects} of sparks) {
|
||||||
|
let g = r.get(spark_group);
|
||||||
|
if (g == null) {
|
||||||
|
g = {spark_group, name, description, type, effects, spark_ids: []};
|
||||||
|
r.set(spark_group, g);
|
||||||
|
}
|
||||||
|
g.spark_ids.push(spark_id);
|
||||||
|
}
|
||||||
|
return [...r.values()];
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as skill from '$lib/data/skill';
|
import * as skill from '$lib/data/skill';
|
||||||
import * as spark from '$lib/data/spark';
|
import * as spark from '$lib/data/spark';
|
||||||
|
import Pick from '$lib/Pick.svelte';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { SvelteMap } from 'svelte/reactivity';
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
|
|
||||||
@@ -14,17 +15,9 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let group = $state(1);
|
let group: spark.SparkGroup | undefined = $state();
|
||||||
|
|
||||||
const groups = $derived.by(() => {
|
const groups = $derived(spark.byGroup(sparks));
|
||||||
const m = new SvelteMap<number, spark.Spark[]>();
|
|
||||||
for (const s of sparks) {
|
|
||||||
const l = m.get(s.spark_group) ?? [];
|
|
||||||
l.push(s);
|
|
||||||
m.set(s.spark_group, l);
|
|
||||||
}
|
|
||||||
return [...m.entries()];
|
|
||||||
});
|
|
||||||
function describe(eff: spark.Effect) {
|
function describe(eff: spark.Effect) {
|
||||||
if (eff.target === spark.Target.Skill) {
|
if (eff.target === spark.Target.Skill) {
|
||||||
const sk = skills.get(eff.value1);
|
const sk = skills.get(eff.value1);
|
||||||
@@ -32,14 +25,13 @@
|
|||||||
}
|
}
|
||||||
return `${spark.TARGET_NAMES[eff.target]} +${eff.value1}`;
|
return `${spark.TARGET_NAMES[eff.target]} +${eff.value1}`;
|
||||||
}
|
}
|
||||||
const cur = $derived(sparks.find((s) => s.spark_group === group));
|
const curEffectGroups = $derived(group?.effects ?? []);
|
||||||
const curEffectGroups = $derived(cur?.effects ?? []);
|
|
||||||
const curDescriptions = $derived(curEffectGroups.map((g) => g.map((e) => describe(e))));
|
const curDescriptions = $derived(curEffectGroups.map((g) => g.map((e) => describe(e))));
|
||||||
const curClass = $derived.by(() => {
|
function sparkClass(t: spark.Type | null | undefined): string[] {
|
||||||
if (cur == null) {
|
if (t == null) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
switch (cur.type) {
|
switch (t) {
|
||||||
case 1:
|
case 1:
|
||||||
return ['stat'];
|
return ['stat'];
|
||||||
case 2:
|
case 2:
|
||||||
@@ -57,16 +49,19 @@
|
|||||||
default:
|
default:
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
const curClass = $derived(sparkClass(group?.type));
|
||||||
|
|
||||||
|
const key = (v: spark.SparkGroup) => ({id: v.spark_group, name: v.name})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<h1 class="text-4xl">Spark Explorer</h1>
|
<h1 class="text-4xl">Spark Explorer</h1>
|
||||||
<form class="mx-auto mt-8 rounded-md p-4 text-center shadow-md ring md:max-w-2xl">
|
<form class="mx-auto mt-8 rounded-md p-4 text-center shadow-md ring md:max-w-2xl">
|
||||||
<select class="w-full" bind:value={group}>
|
<Pick id="spark" items={groups} class="w-full" {key} bind:value={group} required>
|
||||||
{#each groups as [id, s] (id)}
|
{#snippet option(g)}
|
||||||
<option value={id}>{s[0].name}</option>
|
<span class={['spark px-2 py-1 rounded', sparkClass(g.type)]}>{g.name}</span>
|
||||||
{/each}
|
{/snippet}
|
||||||
</select>
|
</Pick>
|
||||||
</form>
|
</form>
|
||||||
<svelte:boundary>
|
<svelte:boundary>
|
||||||
<div
|
<div
|
||||||
@@ -75,22 +70,22 @@
|
|||||||
curClass,
|
curClass,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<span class="block text-xl">{cur?.name}</span>
|
<span class="block text-xl">{group?.name}</span>
|
||||||
{#if cur != null}
|
{#if group != null}
|
||||||
<span class="block">
|
<span class="block">
|
||||||
{spark.TYPE_NAMES[cur.type]} Spark
|
{spark.TYPE_NAMES[group.type]} Spark
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="block">{cur?.description}</span>
|
<span class="block">{group?.description}</span>
|
||||||
</div>
|
</div>
|
||||||
<table class="mx-auto mt-8 w-full max-w-xl table-fixed">
|
<table class="mx-auto mt-8 w-full max-w-xl table-fixed border-separate border-spacing-2">
|
||||||
<caption class="text-xl">Possible Effects</caption>
|
<caption class="text-xl">Possible Effects</caption>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each curDescriptions as s (s)}
|
{#each curDescriptions as s (s)}
|
||||||
<tr class="text-center even:bg-mist-300 dark:even:bg-mist-900">
|
<tr>
|
||||||
<td>
|
<td class="flex gap-2 p-1 text-center border border-mist-400 dark:border-mist-950">
|
||||||
{#each s as n (n)}
|
{#each s as n (n)}
|
||||||
<span class="block">{n}</span>
|
<span class="flex-1 my-auto">{n}</span>
|
||||||
{/each}
|
{/each}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
Reference in New Issue
Block a user