+129
-12
@@ -1,24 +1,141 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { type Character } from '$lib/data/character';
|
import type { Character } from '$lib/data/character';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
import type { ClassValue } from 'svelte/elements';
|
import type { ClassValue } from 'svelte/elements';
|
||||||
|
import { stringsearch } from './stringsearch';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
characters: Character[] | null;
|
characters: Character[] | null;
|
||||||
value: number;
|
value: Character | undefined;
|
||||||
class?: ClassValue | null;
|
class?: ClassValue | null;
|
||||||
optionClass?: ClassValue | null;
|
option?: Snippet<[Character]>;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { id, characters, value = $bindable(), class: className, optionClass, 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 searchedCharas = $derived(stringsearch(search, charas, ({name}) => name) || charas);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
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>
|
||||||
|
|
||||||
<select {id} class={className} bind:value {required}>
|
<div class={className} style={`anchor-name: --anchor-${id}`}>
|
||||||
{#if !required}
|
<span
|
||||||
<option value={0} class={optionClass}></option>
|
{id}
|
||||||
{/if}
|
class="block h-9 content-center hover:cursor-pointer bg-mist-300 dark:bg-mist-900"
|
||||||
{#each characters ?? [] as c (c.chara_id)}
|
role="combobox"
|
||||||
<option value={c.chara_id} class={optionClass}>{c.name}</option>
|
aria-expanded={expanded}
|
||||||
{/each}
|
aria-controls="charaOptions"
|
||||||
</select>
|
aria-haspopup="listbox"
|
||||||
|
onclick={() => popover?.togglePopover()}
|
||||||
|
{onkeydown}
|
||||||
|
tabindex="0"
|
||||||
|
>{value?.name ?? ''}</span>
|
||||||
|
<div
|
||||||
|
class="absolute top-2 shadow-lg open:flex flex-col px-2 skill-tip"
|
||||||
|
style={`position-anchor: --anchor-${id}; position-area: bottom;`}
|
||||||
|
id="charaOptions"
|
||||||
|
role="listbox"
|
||||||
|
popover
|
||||||
|
bind:this={popover}
|
||||||
|
>
|
||||||
|
<input class="my-2 border rounded-md min-h-8 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="w-full h-8 hover:cursor-pointer hover:bg-mist-300 hover:dark:bg-mist-900 text-lg"
|
||||||
|
role="option"
|
||||||
|
aria-selected={value == undefined}
|
||||||
|
tabindex="0"
|
||||||
|
data-chara-id=""
|
||||||
|
onmousedown={() => {value = undefined; search = ''; popover!.hidePopover()}}
|
||||||
|
onfocus={() => value = undefined}
|
||||||
|
{onkeydown}
|
||||||
|
>
|
||||||
|
<span class="italic text-sm">Reset</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#each searchedCharas as c (c.chara_id)}
|
||||||
|
<div
|
||||||
|
class="w-full h-8 hover:cursor-pointer hover:bg-mist-300 hover:dark:bg-mist-900 text-lg"
|
||||||
|
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,56 @@
|
|||||||
|
const WORD_BOUNDARY = " ,!?/-+();#○☆♡'=♪∀゚∴";
|
||||||
|
|
||||||
|
function score(s: string, tt: string): number {
|
||||||
|
let k: number | undefined;
|
||||||
|
let r = 0;
|
||||||
|
let run = 0;
|
||||||
|
for (const c of s) {
|
||||||
|
const j = tt.indexOf(c, k);
|
||||||
|
// If the character isn't in the string, there's a major penalty.
|
||||||
|
if (j < 0) {
|
||||||
|
// The penalty scales with run length, on the assumption that we're
|
||||||
|
// typing something else.
|
||||||
|
// Really this should scale with the longest current run among all
|
||||||
|
// search terms, but that's infeasible to implement.
|
||||||
|
r -= 6 + run*run;
|
||||||
|
run = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
run++;
|
||||||
|
// Characters at word boundaries get extra score.
|
||||||
|
if (j == 0 || WORD_BOUNDARY.includes(tt[j-1])) {
|
||||||
|
r += 2;
|
||||||
|
}
|
||||||
|
// As do characters that *are* word boundaries.
|
||||||
|
if (WORD_BOUNDARY.includes(c)) {
|
||||||
|
r += 2;
|
||||||
|
}
|
||||||
|
// And runs of matches scale with run length.
|
||||||
|
if (j === k) {
|
||||||
|
r += (run+1) * (run+1);
|
||||||
|
} else {
|
||||||
|
run = 0;
|
||||||
|
}
|
||||||
|
k = j + 1;
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fuzzy string search.
|
||||||
|
* @param sub Substring to search for
|
||||||
|
* @param terms Iterable of values to search among
|
||||||
|
* @param map Mapping from term to string to search
|
||||||
|
* @returns Matching terms in decreasing match quality order
|
||||||
|
*/
|
||||||
|
export function stringsearch<T>(sub: string, terms: Iterable<T>, map: (t: T) => string): T[] {
|
||||||
|
const s = sub.toLocaleLowerCase();
|
||||||
|
const scored: [T, number][] = [];
|
||||||
|
for (const t of terms) {
|
||||||
|
const sc = score(s, map(t).toLocaleLowerCase());
|
||||||
|
if (sc >= 0) {
|
||||||
|
scored.push([t, sc]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return scored.sort(([, a], [, b]) => b - a).map(([t,]) => t);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import CharaPick from '$lib/CharaPick.svelte';
|
import CharaPick from '$lib/CharaPick.svelte';
|
||||||
import { AFFINITY_RELATION_DESCRIPTIONS, affinityDetail, type AffinityDetail } from '$lib/data/affinity';
|
import { AFFINITY_RELATION_DESCRIPTIONS, affinityDetail, type AffinityDetail } from '$lib/data/affinity';
|
||||||
import { character, charaNames, type Character } from '$lib/data/character';
|
import { character, type Character } from '$lib/data/character';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { SvelteSet } from 'svelte/reactivity';
|
import { SvelteSet } from 'svelte/reactivity';
|
||||||
|
|
||||||
@@ -12,21 +12,21 @@
|
|||||||
affs = affinities;
|
affs = affinities;
|
||||||
characters = charas.filter((c) => affs.some((d) => d.chara_id === c.chara_id));
|
characters = charas.filter((c) => affs.some((d) => d.chara_id === c.chara_id));
|
||||||
});
|
});
|
||||||
const names = $derived(charaNames(characters));
|
|
||||||
|
|
||||||
let charA: number = $state(1001);
|
let charA: Character | undefined = $state();
|
||||||
let charB: number = $state(0);
|
let charB: Character | undefined = $state();
|
||||||
let charC: number = $state(0);
|
let charC: Character | undefined = $state();
|
||||||
const nameA = $derived(names.get(charA)?.en ?? `Character ${charA}`);
|
|
||||||
const nameB = $derived(names.get(charB)?.en ?? `Character ${charB}`);
|
const charaListA = $derived(characters.filter((c) => c.chara_id != charB?.chara_id && c.chara_id != charC?.chara_id));
|
||||||
const nameC = $derived(names.get(charC)?.en ?? `Character ${charC}`);
|
const charaListB = $derived(characters.filter((c) => c.chara_id != charA?.chara_id && c.chara_id != charC?.chara_id));
|
||||||
|
const charaListC = $derived(characters.filter((c) => c.chara_id != charA?.chara_id && c.chara_id != charB?.chara_id));
|
||||||
|
|
||||||
function groupsFor(affs: AffinityDetail[], chara: number): AffinityDetail[] {
|
function groupsFor(affs: AffinityDetail[], chara: number): AffinityDetail[] {
|
||||||
return chara !== 0 ? affs.filter((d) => d.chara_id === chara) : [];
|
return chara !== 0 ? affs.filter((d) => d.chara_id === chara) : [];
|
||||||
}
|
}
|
||||||
const groupsA = $derived(groupsFor(affs, charA));
|
const groupsA = $derived(groupsFor(affs, charA?.chara_id ?? 0));
|
||||||
const groupsB = $derived(groupsFor(affs, charB));
|
const groupsB = $derived(groupsFor(affs, charB?.chara_id ?? 0));
|
||||||
const groupsC = $derived(groupsFor(affs, charC));
|
const groupsC = $derived(groupsFor(affs, charC?.chara_id ?? 0));
|
||||||
const allGroups = $derived.by(() => {
|
const allGroups = $derived.by(() => {
|
||||||
const seen = new SvelteSet<number>();
|
const seen = new SvelteSet<number>();
|
||||||
const r = [];
|
const r = [];
|
||||||
@@ -41,8 +41,7 @@
|
|||||||
}
|
}
|
||||||
return r;
|
return r;
|
||||||
});
|
});
|
||||||
const selCount = $derived(1 + (charB === 0 ? 0 : 1) + (charC === 0 ? 0 : 1));
|
const selCount = $derived(1 + (charB == null ? 0 : 1) + (charC == null ? 0 : 1));
|
||||||
const duplicate = $derived(charA === charB || charA === charC || (charB !== 0 && charB === charC));
|
|
||||||
const infos = $derived(
|
const infos = $derived(
|
||||||
allGroups
|
allGroups
|
||||||
.map((d) => ({
|
.map((d) => ({
|
||||||
@@ -65,21 +64,19 @@
|
|||||||
<div class="mx-auto mt-8 flex flex-col rounded-md text-center shadow-md ring md:max-w-2xl md:flex-row">
|
<div class="mx-auto mt-8 flex flex-col rounded-md text-center shadow-md ring md:max-w-2xl md:flex-row">
|
||||||
<div class="m-4 flex-1 md:mt-3">
|
<div class="m-4 flex-1 md:mt-3">
|
||||||
<label for="charA" class="hidden md:inline">Character 1</label>
|
<label for="charA" class="hidden md:inline">Character 1</label>
|
||||||
<CharaPick id="charA" {characters} class="w-full" bind:value={charA} required />
|
<CharaPick id="charA" characters={charaListA} class="w-full" bind:value={charA} required />
|
||||||
</div>
|
</div>
|
||||||
<div class="m-4 flex-1 md:mt-3">
|
<div class="m-4 flex-1 md:mt-3">
|
||||||
<label for="charB" class="hidden md:inline">Character 2</label>
|
<label for="charB" class="hidden md:inline">Character 2</label>
|
||||||
<CharaPick id="charB" {characters} class="w-full" bind:value={charB} />
|
<CharaPick id="charB" characters={charaListB} class="w-full" bind:value={charB} />
|
||||||
</div>
|
</div>
|
||||||
<div class="m-4 flex-1 md:mt-3">
|
<div class="m-4 flex-1 md:mt-3">
|
||||||
<label for="charC" class="hidden md:inline">Character 3</label>
|
<label for="charC" class="hidden md:inline">Character 3</label>
|
||||||
<CharaPick id="charC" {characters} class="w-full" bind:value={charC} />
|
<CharaPick id="charC" characters={charaListC} class="w-full" bind:value={charC} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<svelte:boundary>
|
<svelte:boundary>
|
||||||
{#if duplicate}
|
{#if selCount > 1}
|
||||||
<span class="mt-8 block w-full text-center text-lg">Duplicate characters always have zero affinity.</span>
|
|
||||||
{:else if selCount > 1}
|
|
||||||
<span class="mt-8 block w-full text-center text-lg">Total {selCount === 2 ? 'pair' : 'trio'} affinity: {total}</span>
|
<span class="mt-8 block w-full text-center text-lg">Total {selCount === 2 ? 'pair' : 'trio'} affinity: {total}</span>
|
||||||
{/if}
|
{/if}
|
||||||
<table class="mx-auto mt-8 table-fixed">
|
<table class="mx-auto mt-8 table-fixed">
|
||||||
@@ -88,12 +85,12 @@
|
|||||||
<th class="w-32 px-2" scope="col">Group ID</th>
|
<th class="w-32 px-2" scope="col">Group ID</th>
|
||||||
<th class="w-96 px-2" scope="col">Description</th>
|
<th class="w-96 px-2" scope="col">Description</th>
|
||||||
{#if selCount > 1}
|
{#if selCount > 1}
|
||||||
<th class="w-56 px-2" scope="col">{nameA}</th>
|
<th class="w-56 px-2" scope="col">{charA!.name}</th>
|
||||||
{#if charB !== 0}
|
{#if charB != null}
|
||||||
<th class="w-56 px-2" scope="col">{nameB}</th>
|
<th class="w-56 px-2" scope="col">{charB.name}</th>
|
||||||
{/if}
|
{/if}
|
||||||
{#if charC !== 0}
|
{#if charC != null}
|
||||||
<th class="w-56 px-2" scope="col">{nameC}</th>
|
<th class="w-56 px-2" scope="col">{charC.name}</th>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
<th class="w-32 px-2" scope="col">Affinity</th>
|
<th class="w-32 px-2" scope="col">Affinity</th>
|
||||||
@@ -110,14 +107,14 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if selCount > 1}
|
{#if selCount > 1}
|
||||||
<td class="px-4 text-center">{a ? '✓' : ''}</td>
|
<td class="px-4 text-center">{a ? '✓' : ''}</td>
|
||||||
{#if charB !== 0}
|
{#if charB != null}
|
||||||
<td class="px-4 text-center">{b ? '✓' : ''}</td>
|
<td class="px-4 text-center">{b ? '✓' : ''}</td>
|
||||||
{/if}
|
{/if}
|
||||||
{#if charC !== 0}
|
{#if charC != null}
|
||||||
<td class="px-4 text-center">{c ? '✓' : ''}</td>
|
<td class="px-4 text-center">{c ? '✓' : ''}</td>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
<td class="px-4 text-center">{!duplicate && selCount > 1 && counted === selCount ? '+' : ''}{affinity}</td>
|
<td class="px-4 text-center">{selCount > 1 && counted === selCount ? '+' : ''}{affinity}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -17,10 +17,10 @@
|
|||||||
characters = charas.filter((c) => convoMap.has(c.chara_id));
|
characters = charas.filter((c) => convoMap.has(c.chara_id));
|
||||||
});
|
});
|
||||||
|
|
||||||
let charaID = $state(1001);
|
let chara: Character | undefined = $state();
|
||||||
let convo = $state(1);
|
let convo = $state(1);
|
||||||
|
|
||||||
const options = $derived(convoMap.get(charaID) ?? []);
|
const options = $derived(convoMap.get(chara?.chara_id ?? 0) ?? []);
|
||||||
const cur = $derived(options.find((c) => c.number === convo));
|
const cur = $derived(options.find((c) => c.number === convo));
|
||||||
const cur1Name = $derived(cur?.chara_1 && names.get(cur.chara_1)?.en);
|
const cur1Name = $derived(cur?.chara_1 && names.get(cur.chara_1)?.en);
|
||||||
const cur2Name = $derived(cur?.chara_2 && names.get(cur.chara_2)?.en);
|
const cur2Name = $derived(cur?.chara_2 && names.get(cur.chara_2)?.en);
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
<div class="mx-auto mt-8 flex flex-col rounded-md text-center shadow-md ring md:max-w-xl md:flex-row">
|
<div class="mx-auto mt-8 flex flex-col rounded-md text-center shadow-md ring md:max-w-xl md:flex-row">
|
||||||
<div class="m-4 flex-1 md:mt-3">
|
<div class="m-4 flex-1 md:mt-3">
|
||||||
<label for="chara" class="hidden md:inline">Character</label>
|
<label for="chara" class="hidden md:inline">Character</label>
|
||||||
<CharaPick id="chara" {characters} class="w-full" bind:value={charaID} required />
|
<CharaPick id="chara" {characters} class="w-full" bind:value={chara} required />
|
||||||
</div>
|
</div>
|
||||||
<div class="m-4 flex-1 md:mt-3">
|
<div class="m-4 flex-1 md:mt-3">
|
||||||
<label for="convo" class="hidden md:inline">Conversation</label>
|
<label for="convo" class="hidden md:inline">Conversation</label>
|
||||||
|
|||||||
Reference in New Issue
Block a user