export type NationGeneralColumnId = | 'icon' | 'name' | 'officerLevel' | 'expDedLv_1' | 'dedlevel' | 'explevel' | 'stat_1' | 'leadership' | 'strength' | 'intel' | 'troop' | 'goldRice_1' | 'gold' | 'rice' | 'city' | 'crew' | 'specials_1' | 'personal' | 'specialDomestic' | 'specialWar' | 'years_1' | 'belong' | 'killturnAndRefresh_1' | 'refreshScoreTotal'; export type NationGeneralGroupId = 'expDedLv' | 'stat' | 'goldRice' | 'specials' | 'years' | 'killturnAndRefresh'; export type SortDirection = 'asc' | 'desc'; export type NationGeneralViewMode = 'normal' | 'war'; export type NationGeneralColumnState = { colId: NationGeneralColumnId; width: number; hide: boolean; sort: SortDirection | null; sortIndex?: number; }; export type NationGeneralGroupState = { groupId: NationGeneralGroupId; open: boolean; }; export type NationGeneralDisplaySetting = { column: NationGeneralColumnState[]; columnGroup: NationGeneralGroupState[]; }; export type NationGeneralSettingKey = [true, NationGeneralViewMode] | [false, string]; export type TextFilterOperator = 'contains' | 'notContains' | 'equals' | 'notEqual' | 'startsWith' | 'endsWith' | 'blank' | 'notBlank'; export type NumberFilterOperator = | 'equals' | 'notEqual' | 'lessThan' | 'lessThanOrEqual' | 'greaterThan' | 'greaterThanOrEqual' | 'inRange' | 'blank' | 'notBlank'; export type NationGeneralFilterOperator = TextFilterOperator | NumberFilterOperator; export type NationGeneralFilterCondition = { operator: NationGeneralFilterOperator; value: string; valueTo: string; }; export const textFilterOperators: readonly { value: TextFilterOperator; label: string }[] = [ { value: 'contains', label: 'Contains' }, { value: 'notContains', label: 'Not contains' }, { value: 'equals', label: 'Equals' }, { value: 'notEqual', label: 'Not equal' }, { value: 'startsWith', label: 'Starts with' }, { value: 'endsWith', label: 'Ends with' }, { value: 'blank', label: 'Blank' }, { value: 'notBlank', label: 'Not blank' }, ]; export const numberFilterOperators: readonly { value: NumberFilterOperator; label: string }[] = [ { value: 'equals', label: 'Equals' }, { value: 'notEqual', label: 'Not equal' }, { value: 'lessThan', label: 'Less than' }, { value: 'lessThanOrEqual', label: 'Less than or equals' }, { value: 'greaterThan', label: 'Greater than' }, { value: 'greaterThanOrEqual', label: 'Greater than or equals' }, { value: 'inRange', label: 'In range' }, { value: 'blank', label: 'Blank' }, { value: 'notBlank', label: 'Not blank' }, ]; export const DISPLAY_SETTINGS_KEY = 'GeneralListDisplaySetting'; export const DISPLAY_SETTINGS_VERSION = 1; export const lastUsedSettingsKey = (role: string): string => `LastUsedSettingsKey_${role}`; const baseColumns = (): NationGeneralColumnState[] => [ { colId: 'icon', width: 80, hide: false, sort: null }, { colId: 'name', width: 126, hide: false, sort: null }, { colId: 'officerLevel', width: 70, hide: false, sort: null }, { colId: 'expDedLv_1', width: 60, hide: false, sort: null }, { colId: 'dedlevel', width: 70, hide: false, sort: null }, { colId: 'explevel', width: 60, hide: false, sort: null }, { colId: 'stat_1', width: 88, hide: false, sort: null }, { colId: 'leadership', width: 60, hide: false, sort: null }, { colId: 'strength', width: 60, hide: false, sort: null }, { colId: 'intel', width: 60, hide: false, sort: null }, { colId: 'troop', width: 90, hide: true, sort: null }, { colId: 'goldRice_1', width: 80, hide: false, sort: null }, { colId: 'gold', width: 70, hide: false, sort: null }, { colId: 'rice', width: 70, hide: false, sort: null }, { colId: 'city', width: 60, hide: true, sort: null }, { colId: 'crew', width: 70, hide: true, sort: null }, { colId: 'specials_1', width: 80, hide: false, sort: null }, { colId: 'personal', width: 60, hide: false, sort: null }, { colId: 'specialDomestic', width: 60, hide: false, sort: null }, { colId: 'specialWar', width: 60, hide: false, sort: null }, { colId: 'years_1', width: 60, hide: false, sort: null }, { colId: 'belong', width: 60, hide: false, sort: null }, { colId: 'killturnAndRefresh_1', width: 70, hide: false, sort: null }, { colId: 'refreshScoreTotal', width: 70, hide: false, sort: null }, ]; const groupState = (overrides: Partial>): NationGeneralGroupState[] => (['expDedLv', 'stat', 'goldRice', 'specials', 'years', 'killturnAndRefresh'] as const).map((groupId) => ({ groupId, open: overrides[groupId] ?? false, })); const withColumnOverrides = ( columns: NationGeneralColumnState[], overrides: Partial>> ): NationGeneralColumnState[] => columns.map((column) => ({ ...column, ...overrides[column.colId], })); export const defaultNationGeneralDisplaySettings: Record = { normal: { column: withColumnOverrides(baseColumns(), { troop: { hide: true }, city: { hide: true }, crew: { hide: true }, refreshScoreTotal: { sort: 'desc', sortIndex: 0 }, }), columnGroup: groupState({ expDedLv: true, stat: true, goldRice: true, specials: false, years: false, killturnAndRefresh: true, }), }, war: { column: withColumnOverrides(baseColumns(), { icon: { hide: true }, officerLevel: { hide: true }, expDedLv_1: { hide: true }, dedlevel: { hide: true }, explevel: { hide: true }, troop: { hide: false }, city: { hide: false }, crew: { hide: false }, specials_1: { hide: true }, personal: { hide: true }, specialDomestic: { hide: true }, specialWar: { hide: true }, years_1: { hide: true }, belong: { hide: true }, killturnAndRefresh_1: { hide: true }, refreshScoreTotal: { hide: true }, }), columnGroup: groupState({ expDedLv: false, stat: false, goldRice: true, specials: false, years: false, killturnAndRefresh: true, }), }, }; export const cloneNationGeneralDisplaySetting = ( setting: NationGeneralDisplaySetting ): NationGeneralDisplaySetting => ({ column: setting.column.map((column) => ({ ...column })), columnGroup: setting.columnGroup.map((group) => ({ ...group })), }); const validColumnIds = new Set(baseColumns().map((column) => column.colId)); const validGroupIds = new Set(groupState({}).map((group) => group.groupId)); const isSortDirection = (value: unknown): value is SortDirection => value === 'asc' || value === 'desc'; export const normalizeNationGeneralDisplaySetting = (raw: unknown): NationGeneralDisplaySetting | null => { if (!raw || typeof raw !== 'object') { return null; } const candidate = raw as { column?: unknown; columnGroup?: unknown }; if (!Array.isArray(candidate.column) || !Array.isArray(candidate.columnGroup)) { return null; } const fallback = cloneNationGeneralDisplaySetting(defaultNationGeneralDisplaySettings.normal); const rawColumns = new Map>(); for (const value of candidate.column) { if (!value || typeof value !== 'object') continue; const column = value as Record; if (typeof column.colId === 'string' && validColumnIds.has(column.colId as NationGeneralColumnId)) { rawColumns.set(column.colId, column); } } fallback.column = fallback.column.map((column) => { const saved = rawColumns.get(column.colId); if (!saved) return column; return { ...column, width: typeof saved.width === 'number' && saved.width > 0 ? saved.width : column.width, hide: typeof saved.hide === 'boolean' ? saved.hide : column.hide, sort: isSortDirection(saved.sort) ? saved.sort : null, ...(typeof saved.sortIndex === 'number' && saved.sortIndex >= 0 ? { sortIndex: Math.trunc(saved.sortIndex) } : {}), }; }); const rawGroups = new Map(); for (const value of candidate.columnGroup) { if (!value || typeof value !== 'object') continue; const group = value as Record; if ( typeof group.groupId === 'string' && validGroupIds.has(group.groupId as NationGeneralGroupId) && typeof group.open === 'boolean' ) { rawGroups.set(group.groupId, group.open); } } fallback.columnGroup = fallback.columnGroup.map((group) => ({ ...group, open: rawGroups.get(group.groupId) ?? group.open, })); return fallback; }; export const parseStoredDisplaySettings = (raw: string | null): Map => { if (!raw) return new Map(); try { const parsed = JSON.parse(raw) as { version?: unknown; settings?: unknown }; if (parsed.version !== DISPLAY_SETTINGS_VERSION || !Array.isArray(parsed.settings)) return new Map(); const result = new Map(); for (const entry of parsed.settings) { if (!Array.isArray(entry) || typeof entry[0] !== 'string') continue; const setting = normalizeNationGeneralDisplaySetting(entry[1]); if (setting) result.set(entry[0], setting); } return result; } catch { return new Map(); } }; export const serializeDisplaySettings = (settings: Map): string => JSON.stringify({ version: DISPLAY_SETTINGS_VERSION, settings: [...settings.entries()], }); export const parseStoredSettingKey = (raw: string | null): NationGeneralSettingKey | null => { if (!raw) return null; try { const parsed = JSON.parse(raw) as unknown; if ( !Array.isArray(parsed) || parsed.length !== 2 || typeof parsed[0] !== 'boolean' || typeof parsed[1] !== 'string' ) { return null; } if (parsed[0]) return parsed[1] === 'normal' || parsed[1] === 'war' ? [true, parsed[1]] : null; return [false, parsed[1]]; } catch { return null; } }; const initialConsonants = 'ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ'; const hangulInitials = (value: string): string => [...value] .map((character) => { const code = character.charCodeAt(0); if (code < 0xac00 || code > 0xd7a3) return character; return initialConsonants[Math.floor((code - 0xac00) / 588)] ?? character; }) .join(''); const normalizeSearchText = (value: string): string => value.toLocaleLowerCase('ko-KR').replace(/\s+/g, ''); export const matchesKoreanSearch = (value: string, query: string): boolean => { const normalizedQuery = normalizeSearchText(query); if (!normalizedQuery) return true; const normalizedValue = normalizeSearchText(value); return ( normalizedValue.includes(normalizedQuery) || normalizeSearchText(hangulInitials(value)).includes(normalizedQuery) ); }; const normalizedSearchValues = (value: string): [string, string] => [ normalizeSearchText(value), normalizeSearchText(hangulInitials(value)), ]; export const matchesTextFilterCondition = (value: string | null, condition: NationGeneralFilterCondition): boolean => { const blank = value === null || value === ''; if (condition.operator === 'blank') return blank; if (condition.operator === 'notBlank') return !blank; const query = normalizeSearchText(condition.value); if (!query) return true; if (value === null) return false; const candidates = normalizedSearchValues(value); const matches = (predicate: (candidate: string) => boolean): boolean => candidates.some(predicate); switch (condition.operator) { case 'notContains': return !matches((candidate) => candidate.includes(query)); case 'equals': return matches((candidate) => candidate === query); case 'notEqual': return !matches((candidate) => candidate === query); case 'startsWith': return matches((candidate) => candidate.startsWith(query)); case 'endsWith': return matches((candidate) => candidate.endsWith(query)); default: return matches((candidate) => candidate.includes(query)); } }; const parseFiniteNumber = (raw: string): number | null => { const normalized = raw.trim(); if (!normalized) return null; const parsed = Number(normalized); return Number.isFinite(parsed) ? parsed : null; }; export const matchesNumberFilterCondition = ( value: number | null, condition: NationGeneralFilterCondition ): boolean => { const blank = value === null || !Number.isFinite(value); if (condition.operator === 'blank') return blank; if (condition.operator === 'notBlank') return !blank; if (blank) return false; const expected = parseFiniteNumber(condition.value); if (expected === null) return true; switch (condition.operator) { case 'notEqual': return value !== expected; case 'lessThan': return value < expected; case 'lessThanOrEqual': return value <= expected; case 'greaterThan': return value > expected; case 'greaterThanOrEqual': return value >= expected; case 'inRange': { const expectedTo = parseFiniteNumber(condition.valueTo); return ( expectedTo === null || (value >= Math.min(expected, expectedTo) && value <= Math.max(expected, expectedTo)) ); } default: return value === expected; } }; export const matchesNumberSearch = (value: number | null, query: string): boolean => { const normalized = query.trim(); if (!normalized) return true; if (value === null || !Number.isFinite(value)) return false; const match = /^(<=|>=|<|>|=)?\s*(-?\d+(?:\.\d+)?)$/.exec(normalized); if (!match) return false; const expected = Number(match[2]); switch (match[1] ?? '=') { case '<': return value < expected; case '<=': return value <= expected; case '>': return value > expected; case '>=': return value >= expected; default: return value === expected; } }; export const compareGridValues = (left: string | number | null, right: string | number | null): number => { if (left === right) return 0; if (left === null) return 1; if (right === null) return -1; if (typeof left === 'number' && typeof right === 'number') return left - right; return String(left).localeCompare(String(right), 'ko-KR', { numeric: true }); };