-
{{ t('filter.common') }}
+
{{ t('filter.common') }}
+
+
+
+ {{ t('filter.dimension') }}
+
+
+
-
-
-
{{ group.label }}
-
+
+
{{
+ t('filter.pattern')
+ }}
+
+
+
+
+
+
diff --git a/app/components/PatternChip.vue b/app/components/PatternChip.vue
index 26d3c7c..65dc248 100644
--- a/app/components/PatternChip.vue
+++ b/app/components/PatternChip.vue
@@ -3,6 +3,7 @@ defineProps<{
signature: string
count: number
active: boolean
+ label: string
}>()
defineEmits<{ toggle: [] }>()
@@ -20,7 +21,9 @@ defineEmits<{ toggle: [] }>()
? 'border-$c-ink bg-$c-ink text-$c-paper'
: 'border-rule bg-paper text-soft hover:border-ink/30'
"
- :disabled="count === 0"
+ :disabled="count === 0 && !active"
+ :title="label"
+ :aria-label="label"
:aria-pressed="active"
@click="$emit('toggle')"
>
diff --git a/app/components/SegChoice.vue b/app/components/SegChoice.vue
index 725a1f0..dd93123 100644
--- a/app/components/SegChoice.vue
+++ b/app/components/SegChoice.vue
@@ -1,8 +1,20 @@
@@ -11,17 +23,19 @@ const model = defineModel({ required: true })
v-for="option in options"
:key="option.value"
type="button"
- class="flex items-center rounded px-2.5 text-xs transition-colors duration-150 focus-ring"
+ class="flex items-center gap-1 rounded px-2.5 text-xs transition-colors duration-150 focus-ring"
:class="
model === option.value
? 'bg-paper text-ink shadow-[0_1px_2px_rgb(0_0_0/0.06)]'
: 'text-mute hover:text-soft'
"
:title="option.title"
+ :aria-label="option.ariaLabel"
:aria-pressed="model === option.value"
- @click="model = option.value"
+ @click="choose(option.value)"
>
{{ option.label }}
+ {{ option.suffix }}
diff --git a/app/components/VarietyChip.vue b/app/components/VarietyChip.vue
new file mode 100644
index 0000000..7bd3ef0
--- /dev/null
+++ b/app/components/VarietyChip.vue
@@ -0,0 +1,31 @@
+
+
+
+
+
diff --git a/app/composables/chars.ts b/app/composables/chars.ts
index 8d8eecb..fea8a22 100644
--- a/app/composables/chars.ts
+++ b/app/composables/chars.ts
@@ -1,5 +1,14 @@
import charsRaw from '~~/public/data/chars.json?raw'
import { createListingMatcher, listingOptionsFor } from '~~/shared/listings.ts'
+import {
+ applicablePatternChoices,
+ DEFAULT_PATTERN_CHOICES,
+ parsePatternChoices,
+ patternChoicesMatch,
+ serializePatternChoices,
+ toggleExactPatternChoice,
+ toggleVarietyChoice,
+} from '~~/shared/patterns.ts'
import { plainReading } from '~~/shared/readings.ts'
import { projectSignature, varietyOf } from '~~/shared/row.ts'
import {
@@ -175,9 +184,9 @@ export function useChars() {
)
const patterns = useQueryState(
'p',
- [] as string[],
- asList.parse,
- asList.serialize,
+ [...DEFAULT_PATTERN_CHOICES] as string[],
+ parsePatternChoices,
+ serializePatternChoices,
)
const sortKey = useQueryState(
's',
@@ -329,12 +338,14 @@ export function useChars() {
const rows = computed(() => {
// A selection made against a different set of columns describes partitions
// that no longer exist; it waits, unapplied, until those columns return.
- const chosen = new Set(
- patterns.value.filter((p) => p.length === visibleRegions.value.length),
- )
- const filtered = chosen.size
+ const available = patternGroups.value.flatMap((group) => group.patterns)
+ const chosen = applicablePatternChoices(patterns.value, available)
+ const filtered = chosen.length
? base.value.filter((row) =>
- chosen.has(signatureOf(row, dimension.value, regionIndices.value)),
+ patternChoicesMatch(
+ signatureOf(row, dimension.value, regionIndices.value),
+ chosen,
+ ),
)
: base.value
@@ -408,7 +419,8 @@ export function useChars() {
const dirty = computed(
() =>
- patterns.value.length > 0 ||
+ serializePatternChoices(patterns.value) !==
+ serializePatternChoices(DEFAULT_PATTERN_CHOICES) ||
query.value !== '' ||
common.value.length > 0 ||
tiers.value.length > 0 ||
@@ -417,7 +429,7 @@ export function useChars() {
)
function reset() {
- patterns.value = []
+ patterns.value = [...DEFAULT_PATTERN_CHOICES]
query.value = ''
common.value = []
tiers.value = []
@@ -425,10 +437,11 @@ export function useChars() {
}
function togglePattern(pattern: string) {
- const chosen = new Set(patterns.value)
- if (chosen.has(pattern)) chosen.delete(pattern)
- else chosen.add(pattern)
- patterns.value = [...chosen]
+ patterns.value = toggleExactPatternChoice(patterns.value, pattern)
+ }
+
+ function toggleVariety(variety: number, groupPatterns: readonly string[]) {
+ patterns.value = toggleVarietyChoice(patterns.value, variety, groupPatterns)
}
return {
@@ -453,6 +466,7 @@ export function useChars() {
dirty,
reset,
togglePattern,
+ toggleVariety,
strokeBounds: STROKE_BOUNDS,
}
}
diff --git a/app/locales/ja-jp.ts b/app/locales/ja-jp.ts
index 752411d..5aa3956 100644
--- a/app/locales/ja-jp.ts
+++ b/app/locales/ja-jp.ts
@@ -72,6 +72,8 @@ export const jaJP: typeof zhCN = {
strokes: '画数',
cp: 'コードポイント',
freq: '文字頻度',
+ asc: '昇順',
+ desc: '降順',
},
table: {
strokes: '画数',
diff --git a/app/locales/zh-cn.ts b/app/locales/zh-cn.ts
index a83cab5..54347b3 100644
--- a/app/locales/zh-cn.ts
+++ b/app/locales/zh-cn.ts
@@ -66,6 +66,8 @@ export const zhCN = {
strokes: '笔画',
cp: '码点',
freq: '字频',
+ asc: '正序',
+ desc: '倒序',
},
table: {
strokes: '笔画',
diff --git a/app/locales/zh-hk.ts b/app/locales/zh-hk.ts
index fe4434d..2d71abe 100644
--- a/app/locales/zh-hk.ts
+++ b/app/locales/zh-hk.ts
@@ -68,6 +68,8 @@ export const zhHK: typeof zhCN = {
strokes: '筆畫',
cp: '碼點',
freq: '字頻',
+ asc: '正序',
+ desc: '倒序',
},
table: {
strokes: '筆畫',
diff --git a/app/locales/zh-tw.ts b/app/locales/zh-tw.ts
index 61204f5..44a2b11 100644
--- a/app/locales/zh-tw.ts
+++ b/app/locales/zh-tw.ts
@@ -68,6 +68,8 @@ export const zhTW: typeof zhCN = {
strokes: '筆畫',
cp: '碼位',
freq: '字頻',
+ asc: '正序',
+ desc: '倒序',
},
table: {
strokes: '筆畫',
diff --git a/scripts/tests/patterns.test.ts b/scripts/tests/patterns.test.ts
new file mode 100644
index 0000000..f85ef3d
--- /dev/null
+++ b/scripts/tests/patterns.test.ts
@@ -0,0 +1,82 @@
+import { describe, expect, it } from 'vitest'
+import {
+ applicablePatternChoices,
+ DEFAULT_PATTERN_CHOICES,
+ parsePatternChoices,
+ patternChoicesMatch,
+ serializePatternChoices,
+ toggleExactPatternChoice,
+ toggleVarietyChoice,
+} from '../../shared/patterns.ts'
+
+const FOUR_COLUMN_PATTERNS = [
+ '0000',
+ '0001',
+ '0010',
+ '0011',
+ '0012',
+ '0100',
+ '0101',
+ '0102',
+ '0110',
+ '0111',
+ '0112',
+ '0120',
+ '0121',
+ '0122',
+ '0123',
+]
+
+describe('broad difference-pattern choices', () => {
+ it('excludes identical rows by default', () => {
+ expect(patternChoicesMatch('0000', DEFAULT_PATTERN_CHOICES)).toBe(false)
+ expect(patternChoicesMatch('0001', DEFAULT_PATTERN_CHOICES)).toBe(true)
+ expect(patternChoicesMatch('0112', DEFAULT_PATTERN_CHOICES)).toBe(true)
+ expect(patternChoicesMatch('0123', DEFAULT_PATTERN_CHOICES)).toBe(true)
+ })
+
+ it('lets an exact pattern replace its broad group', () => {
+ expect(toggleExactPatternChoice(DEFAULT_PATTERN_CHOICES, '0001')).toEqual([
+ '0001',
+ 'v3',
+ 'v4',
+ ])
+ })
+
+ it('lets the broad group replace its exact children', () => {
+ expect(
+ toggleVarietyChoice(['0001', '0010', 'v3', 'v4'], 2, ['0001', '0010']),
+ ).toEqual(['v2', 'v3', 'v4'])
+ })
+})
+
+describe('pattern choices and hidden columns', () => {
+ it('keeps four-column choices serialized but does not apply them to three columns', () => {
+ const choices = ['0123', 'v2', 'v4']
+ const available = ['000', '001', '010', '011', '012']
+ expect(applicablePatternChoices(choices, available)).toEqual(['v2'])
+ expect(choices).toEqual(['0123', 'v2', 'v4'])
+ })
+
+ it('restores every applicable choice when all columns return', () => {
+ expect(
+ applicablePatternChoices(['0123', 'v2', 'v4'], FOUR_COLUMN_PATTERNS),
+ ).toEqual(['0123', 'v2', 'v4'])
+ })
+})
+
+describe('pattern choice URL encoding', () => {
+ it('round-trips broad and exact choices', () => {
+ const value = ['v3', '0001', 'v2']
+ expect(parsePatternChoices(serializePatternChoices(value))).toEqual([
+ '0001',
+ 'v2',
+ 'v3',
+ ])
+ })
+
+ it('uses an explicit value for an unfiltered table', () => {
+ expect(serializePatternChoices([])).toBe('all')
+ expect(parsePatternChoices('all')).toEqual([])
+ })
+})
diff --git a/shared/patterns.ts b/shared/patterns.ts
new file mode 100644
index 0000000..c4a6c9b
--- /dev/null
+++ b/shared/patterns.ts
@@ -0,0 +1,98 @@
+import { varietyOf } from './row.ts'
+
+/**
+ * A broad pattern choice: `v2` means every partition with two distinct forms.
+ * Exact choices keep using their partition signature (`0001`, `0112`, ...),
+ * so existing shared URLs continue to mean exactly what they did before.
+ */
+const VARIETY_CHOICE = /^v([1-9]\d*)$/
+
+export const DEFAULT_PATTERN_CHOICES = ['v2', 'v3', 'v4'] as const
+export const ALL_PATTERN_CHOICES = 'all'
+
+export const varietyChoice = (variety: number): string => `v${variety}`
+
+export function choiceVariety(choice: string): number | undefined {
+ const match = VARIETY_CHOICE.exec(choice)
+ return match ? Number(match[1]) : undefined
+}
+
+/** Does one broad or exact choice include this partition? */
+export function patternChoiceMatches(
+ signature: string,
+ choice: string,
+): boolean {
+ const variety = choiceVariety(choice)
+ return variety === undefined
+ ? choice === signature
+ : variety === varietyOf(signature)
+}
+
+/** A row passes when any currently applicable choice includes its partition. */
+export const patternChoicesMatch = (
+ signature: string,
+ choices: readonly string[],
+): boolean => choices.some((choice) => patternChoiceMatches(signature, choice))
+
+/**
+ * Choices made for hidden columns stay serialized but do not filter the table.
+ * Exact signatures are scoped by their length; broad choices are available
+ * only when that many forms can exist among the columns currently on show.
+ */
+export function applicablePatternChoices(
+ choices: readonly string[],
+ availablePatterns: readonly string[],
+): string[] {
+ const exact = new Set(availablePatterns)
+ const varieties = new Set(availablePatterns.map(varietyOf))
+ return choices.filter((choice) => {
+ const variety = choiceVariety(choice)
+ return variety === undefined ? exact.has(choice) : varieties.has(variety)
+ })
+}
+
+/**
+ * The group button switches between a broad choice and no choice. When exact
+ * children are selected, pressing it replaces those children with the broad
+ * choice instead, which reads as “all patterns with N forms”.
+ */
+export function toggleVarietyChoice(
+ choices: readonly string[],
+ variety: number,
+ groupPatterns: readonly string[],
+): string[] {
+ const chosen = new Set(choices)
+ const broad = varietyChoice(variety)
+ if (chosen.has(broad)) chosen.delete(broad)
+ else {
+ for (const pattern of groupPatterns) chosen.delete(pattern)
+ chosen.add(broad)
+ }
+ return [...chosen].toSorted()
+}
+
+/** Choosing a child turns a broad group into a precise selection. */
+export function toggleExactPatternChoice(
+ choices: readonly string[],
+ signature: string,
+): string[] {
+ const chosen = new Set(choices)
+ if (chosen.has(signature)) chosen.delete(signature)
+ else {
+ chosen.delete(varietyChoice(varietyOf(signature)))
+ chosen.add(signature)
+ }
+ return [...chosen].toSorted()
+}
+
+/** `all` is the explicit URL spelling for no pattern filter. */
+export function parsePatternChoices(raw: string): string[] {
+ if (raw === ALL_PATTERN_CHOICES) return []
+ return [...new Set(raw.split(',').filter(Boolean))].toSorted()
+}
+
+export function serializePatternChoices(choices: readonly string[]): string {
+ return choices.length
+ ? [...new Set(choices)].toSorted().join(',')
+ : ALL_PATTERN_CHOICES
+}