Changed to CTY database

This commit is contained in:
Jakob Kordež 2024-06-24 23:16:17 +02:00
parent 4db1c4e1dc
commit 49273955f8
16 changed files with 33363 additions and 3322 deletions

View File

@ -1,4 +1,4 @@
{
"vite.autoStart": false,
"cSpell.words": ["callsign", "dxcc", "adif", "graphviz", "clublog"]
"cSpell.words": ["adif", "callsign", "clublog", "dxcc", "graphviz", "ituz", "timez"]
}

View File

@ -2,10 +2,26 @@
An app for checking the format of a callsign and finding the country it belongs to.
## Obtaining the Clublog prefix database
## Downloading the country database
Data about countries can be downloaded from either [Amateur Radio Country Files by AD1C](https://www.country-files.com/) or [Clublog](https://clublog.freshdesk.com/support/solutions/articles/54902-downloading-the-prefixes-and-exceptions-as-xml).
### Obtaining the AD1C country database
Download any `cty.dat` file from the [Country Files](https://www.country-files.com/) website. I recommend using the [Big CTY](https://www.country-files.com/big-cty/) file.
Parsing can be done with the script [`cty-dat-parser.ts`](./scripts/cty-dat-parser.ts).
Instead of `cty.dat` you can also use the `cty.csv` file. Parsing can be done with the script [`cty-csv-parser.ts`](./scripts/cty-csv-parser.ts).
`Note:` The `cty.dat` does not contain the `adif` DXCC number.
### Obtaining the Clublog prefix database `OBSOLETE`
Read how to obtain the Clublog prefix file [here](https://clublog.freshdesk.com/support/solutions/articles/54902-downloading-the-prefixes-and-exceptions-as-xml)
Parsing can be done with the script [`clublog-parser.ts`](./scripts/clublog-parser.ts).
## Deploying the app
The app can be deployed using the following command:

View File

@ -102,7 +102,7 @@ for (const entity of doc.clublog.entities[0].entity) {
const end = entity.end?.[0];
if (end && new Date(end) < now) continue;
entities.push({
entity: id,
id,
name,
cqz: cqz ? parseInt(cqz) : undefined,
cont: cont ? cont : undefined

150
scripts/cty-csv-parser.ts Normal file
View File

@ -0,0 +1,150 @@
import path from 'path';
import { fileURLToPath } from 'url';
const filePath = process.argv[2];
if (!filePath) {
console.error('Please provide a file path as the first argument');
process.exit(1);
}
const OUT_DIR = '../src/assets/';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const outDir = path.resolve(__dirname, OUT_DIR);
console.log('Outputting to', outDir);
import fs from 'fs';
import { DxccEntity } from '../src/lib/models/dxcc-entity';
// Parse the cty.dat file
const file = fs.readFileSync(filePath, 'utf8');
const entities: DxccEntity[] = [];
const prefixes: { call: string; entity: number }[] = [];
console.log('Parsing', filePath);
for (const entity of file.split(';')) {
if (!entity.trim()) continue;
const [primaryPrefixRaw, name, dxcc, cont, cqz, ituz, lat, long, timez, otherPrefixesRaw] = entity
.split(',')
.map((s) => s.trim());
const hasStar = primaryPrefixRaw.startsWith('*');
const primaryPrefix = primaryPrefixRaw.replace('*', '');
const otherPrefixes = otherPrefixesRaw.split(' ').map((s) => s.trim());
const entityId = entities.length + 1;
entities.push({
id: entityId,
dxcc: parseInt(dxcc),
primaryPrefix,
name,
cqz: parseInt(cqz),
ituz: parseInt(ituz),
cont,
lat: parseFloat(lat),
long: parseFloat(long),
timezone: parseFloat(timez)
});
for (const prefix of otherPrefixes) {
const find = prefixes.find((p) => p.call === prefix);
if (find) {
// console.error('Duplicate prefix', prefix, hasStar ? 'Overwriting' : 'Skipping');
if (hasStar) find.entity = entityId;
} else {
prefixes.push({ call: prefix, entity: entityId });
}
}
}
console.log('Parsed', prefixes.length, 'prefixes');
// Build the initial trie
import { TrieNode } from '../src/lib/models/trie';
const root = new TrieNode();
for (const { call: callRaw, entity } of prefixes) {
const [, call] = callRaw.match(/^=?((?:[A-Z\d/])+)(.*)/)!;
const isExact = callRaw.startsWith('=');
root.insert(call, entity, isExact);
}
console.log('Built trie with', root.getAllNodes().size, 'nodes');
// Collapse nodes that do not cause changes
root.collapseNodes();
console.log('Collapsed trie with', root.getAllNodes().size, 'nodes');
// Merge as many nodes as possible
const nodes = new Map([...root.getAllNodes()].map((node) => [node.id, node]));
const parents: Map<number, TrieNode[]> = new Map();
for (const node of nodes.values()) {
for (const child of node.children.values()) {
const list = parents.get(child.id) ?? [];
list.push(node);
parents.set(child.id, list);
}
}
let anyChanged = true;
while (anyChanged) {
anyChanged = false;
const hashed = new Map<string, TrieNode>();
for (const node of nodes.values()) {
const hash = node.hash();
const existing = hashed.get(hash);
if (!existing) {
hashed.set(hash, node);
continue;
}
if (!existing.canMerge(node)) {
throw new Error('Merge conflict false positive');
}
for (const parent of parents.get(node.id) ?? []) {
for (const [k, v] of parent.children) {
if (v === node) {
parent.children.set(k, existing);
}
}
}
parents.delete(node.id);
nodes.delete(node.id);
anyChanged = true;
}
}
console.log('Finished merge with', nodes.size, 'nodes');
// Validate the trie
for (const { call: callRaw, entity } of prefixes) {
const [, call] = callRaw.match(/^=?((?:[A-Z\d/])+)(.*)/)!;
let node: TrieNode | null = root;
let currentEntity: number | null = null;
for (const c of call) {
node = node.children.get(c) ?? null;
if (!node) break;
currentEntity = node.entity ?? currentEntity;
}
if (currentEntity !== entity && node?.exactEntity !== entity) {
console.error('Failed to find', call, entity);
console.log('Found', node?.entity, node?.exactEntity, currentEntity);
}
}
// Minimize node IDs
let i = 0;
for (const node of root.getAllNodes()) {
node.id = i++;
}
// Output the trie
const out = root.encodeToString();
fs.writeFileSync(path.join(outDir, 'dxcc-tree.txt'), out);
// Output the entities
entities.sort((a, b) => a.id - b.id);
fs.writeFileSync(path.join(outDir, 'dxcc-entities.json'), JSON.stringify(entities, null, '\t'));

148
scripts/cty-dat-parser.ts Normal file
View File

@ -0,0 +1,148 @@
import path from 'path';
import { fileURLToPath } from 'url';
const filePath = process.argv[2];
if (!filePath) {
console.error('Please provide a file path as the first argument');
process.exit(1);
}
const OUT_DIR = '../src/assets/';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const outDir = path.resolve(__dirname, OUT_DIR);
console.log('Outputting to', outDir);
import fs from 'fs';
import { DxccEntity } from '../src/lib/models/dxcc-entity';
// Parse the cty.dat file
const file = fs.readFileSync(filePath, 'utf8');
const entities: DxccEntity[] = [];
const prefixes: { call: string; entity: number }[] = [];
console.log('Parsing', filePath);
for (const entity of file.split(';')) {
if (!entity.trim()) continue;
const [name, cqz, ituz, cont, lat, long, timez, primaryPrefixRaw, otherPrefixesRaw] = entity
.split(':')
.map((s) => s.trim());
const hasStar = primaryPrefixRaw.startsWith('*');
const primaryPrefix = primaryPrefixRaw.replace('*', '');
const otherPrefixes = otherPrefixesRaw.split(',').map((s) => s.trim());
const entityId = entities.length + 1;
entities.push({
id: entityId,
primaryPrefix,
name,
cqz: parseInt(cqz),
ituz: parseInt(ituz),
cont,
lat: parseFloat(lat),
long: parseFloat(long),
timezone: parseFloat(timez)
});
for (const prefix of otherPrefixes) {
const find = prefixes.find((p) => p.call === prefix);
if (find) {
// console.error('Duplicate prefix', prefix, hasStar ? 'Overwriting' : 'Skipping');
if (hasStar) find.entity = entityId;
} else {
prefixes.push({ call: prefix, entity: entityId });
}
}
}
console.log('Parsed', prefixes.length, 'prefixes');
// Build the initial trie
import { TrieNode } from '../src/lib/models/trie';
const root = new TrieNode();
for (const { call: callRaw, entity } of prefixes) {
const [, call] = callRaw.match(/^=?((?:[A-Z\d/])+)(.*)/)!;
const isExact = callRaw.startsWith('=');
root.insert(call, entity, isExact);
}
console.log('Built trie with', root.getAllNodes().size, 'nodes');
// Collapse nodes that do not cause changes
root.collapseNodes();
console.log('Collapsed trie with', root.getAllNodes().size, 'nodes');
// Merge as many nodes as possible
const nodes = new Map([...root.getAllNodes()].map((node) => [node.id, node]));
const parents: Map<number, TrieNode[]> = new Map();
for (const node of nodes.values()) {
for (const child of node.children.values()) {
const list = parents.get(child.id) ?? [];
list.push(node);
parents.set(child.id, list);
}
}
let anyChanged = true;
while (anyChanged) {
anyChanged = false;
const hashed = new Map<string, TrieNode>();
for (const node of nodes.values()) {
const hash = node.hash();
const existing = hashed.get(hash);
if (!existing) {
hashed.set(hash, node);
continue;
}
if (!existing.canMerge(node)) {
throw new Error('Merge conflict false positive');
}
for (const parent of parents.get(node.id) ?? []) {
for (const [k, v] of parent.children) {
if (v === node) {
parent.children.set(k, existing);
}
}
}
parents.delete(node.id);
nodes.delete(node.id);
anyChanged = true;
}
}
console.log('Finished merge with', nodes.size, 'nodes');
// Validate the trie
for (const { call: callRaw, entity } of prefixes) {
const [, call] = callRaw.match(/^=?((?:[A-Z\d/])+)(.*)/)!;
let node: TrieNode | null = root;
let currentEntity: number | null = null;
for (const c of call) {
node = node.children.get(c) ?? null;
if (!node) break;
currentEntity = node.entity ?? currentEntity;
}
if (currentEntity !== entity && node?.exactEntity !== entity) {
console.error('Failed to find', call, entity);
console.log('Found', node?.entity, node?.exactEntity, currentEntity);
}
}
// Minimize node IDs
let i = 0;
for (const node of root.getAllNodes()) {
node.id = i++;
}
// Output the trie
const out = root.encodeToString();
fs.writeFileSync(path.join(outDir, 'dxcc-tree.txt'), out);
// Output the entities
fs.writeFileSync(path.join(outDir, 'dxcc-entities.json'), JSON.stringify(entities, null, '\t'));

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,10 @@
import { describe, expect, test } from 'vitest';
import { parseCallsign } from './callsign';
import { dxccEntities } from './dxcc-util';
const s5ID = [...dxccEntities.values()].find((e) => e.primaryPrefix === 'S5')?.id;
const svID = [...dxccEntities.values()].find((e) => e.primaryPrefix === 'SV')?.id;
const svaID = [...dxccEntities.values()].find((e) => e.primaryPrefix === 'SV/a')?.id;
describe('parseCallsign', () => {
test('S52KJ', () => {
@ -10,8 +15,7 @@ describe('parseCallsign', () => {
expect(data?.baseSuffix).toBe('2KJ');
expect(data?.base).toBe('S52KJ');
expect(data?.secondarySuffix).toBe(null);
expect(data?.suffixPartOf).toBe(null);
expect(data?.baseDxcc).toBe(499);
expect(data?.baseDxcc).toBe(s5ID);
expect(data?.prefixDxcc).toBe(null);
});
@ -23,8 +27,7 @@ describe('parseCallsign', () => {
expect(data?.baseSuffix).toBe('2KJ');
expect(data?.base).toBe('S52KJ');
expect(data?.secondarySuffix).toBe(null);
expect(data?.suffixPartOf).toBe(null);
expect(data?.baseDxcc).toBe(499);
expect(data?.baseDxcc).toBe(s5ID);
expect(data?.prefixDxcc).toBe(null);
});
@ -36,35 +39,32 @@ describe('parseCallsign', () => {
expect(data?.baseSuffix).toBe('2KJ');
expect(data?.base).toBe('S52KJ');
expect(data?.secondarySuffix).toBe('P');
expect(data?.suffixPartOf).toBe(null);
expect(data?.baseDxcc).toBe(499);
expect(data?.baseDxcc).toBe(s5ID);
expect(data?.prefixDxcc).toBe(null);
});
test('9A/S52KJ', () => {
const data = parseCallsign('9A/S52KJ');
test('SV/S52KJ', () => {
const data = parseCallsign('SV/S52KJ');
expect(data).not.toBe(null);
expect(data?.secondaryPrefix).toBe('9A');
expect(data?.secondaryPrefix).toBe('SV');
expect(data?.basePrefix).toBe('S5');
expect(data?.baseSuffix).toBe('2KJ');
expect(data?.base).toBe('S52KJ');
expect(data?.secondarySuffix).toBe(null);
expect(data?.suffixPartOf).toBe(null);
expect(data?.baseDxcc).toBe(499);
expect(data?.prefixDxcc).toBe(497);
expect(data?.baseDxcc).toBe(s5ID);
expect(data?.prefixDxcc).toBe(svID);
});
test('9A/S52KJ/P', () => {
const data = parseCallsign('9A/S52KJ/P');
test('SV/S52KJ/P', () => {
const data = parseCallsign('SV/S52KJ/P');
expect(data).not.toBe(null);
expect(data?.secondaryPrefix).toBe('9A');
expect(data?.secondaryPrefix).toBe('SV');
expect(data?.basePrefix).toBe('S5');
expect(data?.baseSuffix).toBe('2KJ');
expect(data?.base).toBe('S52KJ');
expect(data?.secondarySuffix).toBe('P');
expect(data?.suffixPartOf).toBe(null);
expect(data?.baseDxcc).toBe(499);
expect(data?.prefixDxcc).toBe(497);
expect(data?.baseDxcc).toBe(s5ID);
expect(data?.prefixDxcc).toBe(svID);
});
test('S52KJ/A', () => {
@ -75,21 +75,19 @@ describe('parseCallsign', () => {
expect(data?.baseSuffix).toBe('2KJ');
expect(data?.base).toBe('S52KJ');
expect(data?.secondarySuffix).toBe('A');
expect(data?.suffixPartOf).toBe(null);
expect(data?.baseDxcc).toBe(499);
expect(data?.baseDxcc).toBe(s5ID);
expect(data?.prefixDxcc).toBe(null);
});
test('SV1KJ/A', () => {
const data = parseCallsign('SV1KJ/A');
test('SV2RSG/A', () => {
const data = parseCallsign('SV2RSG/A');
expect(data).not.toBe(null);
expect(data?.secondaryPrefix).toBe(null);
expect(data?.basePrefix).toBe('SV');
expect(data?.baseSuffix).toBe('1KJ');
expect(data?.base).toBe('SV1KJ');
expect(data?.basePrefix).toBe('SV2RSG/A');
expect(data?.baseSuffix).toBe('');
expect(data?.base).toBe('SV2RSG');
expect(data?.secondarySuffix).toBe('A');
expect(data?.suffixPartOf).toBe('base');
expect(data?.baseDxcc).toBe(180);
expect(data?.baseDxcc).toBe(svaID);
expect(data?.prefixDxcc).toBe(null);
});
@ -101,8 +99,7 @@ describe('parseCallsign', () => {
expect(data?.baseSuffix).toBe('2KJ');
expect(data?.base).toBe('S52KJ');
expect(data?.secondarySuffix).toBe('A');
expect(data?.suffixPartOf).toBe('prefix');
expect(data?.baseDxcc).toBe(499);
expect(data?.prefixDxcc).toBe(180);
expect(data?.baseDxcc).toBe(s5ID);
expect(data?.prefixDxcc).toBe(svID);
});
});

View File

@ -8,8 +8,6 @@ type CallsignData = {
baseSuffix: string | null;
base: string;
secondarySuffix: string | null;
suffixPartOf: 'base' | 'prefix' | null;
suffixDescription?: string;
baseDxcc: number | null;
prefixDxcc: number | null;
};
@ -24,17 +22,13 @@ export function parseCallsign(callsign: string): CallsignData | null {
const secondaryPrefix = match[1]?.slice(0, -1) ?? null;
const base = match[2];
const secondarySuffix = match[3]?.slice(1) ?? null;
const baseDxcc = findDxcc(base + '/' + secondarySuffix);
const baseWithSuffix = base + (secondarySuffix ? '/' + secondarySuffix : '');
const baseDxcc = findDxcc(baseWithSuffix);
const prefixDxcc = secondaryPrefix ? findDxcc(callsign) : null;
const basePrefix = baseDxcc ? base.slice(0, baseDxcc.prefixLength) : null;
const baseSuffix = baseDxcc ? base.slice(baseDxcc.prefixLength) : null;
const suffixPartOf = prefixDxcc?.withSuffix
? 'prefix'
: !prefixDxcc && baseDxcc?.withSuffix
? 'base'
: null;
const basePrefix = baseDxcc ? baseWithSuffix.slice(0, baseDxcc.matchLength) : null;
const baseSuffix = baseDxcc ? baseWithSuffix.slice(baseDxcc.matchLength).split('/')[0] : null;
return {
secondaryPrefix,
@ -42,26 +36,13 @@ export function parseCallsign(callsign: string): CallsignData | null {
baseSuffix,
base,
secondarySuffix,
suffixPartOf,
baseDxcc: baseDxcc?.entity || null,
prefixDxcc: prefixDxcc?.entity || null
baseDxcc: baseDxcc?.entityId || null,
prefixDxcc: prefixDxcc?.entityId || null
};
}
export function getSecondarySuffixDescription(callsign: CallsignData): string | null {
if (!callsign.secondarySuffix) {
return null;
}
if (callsign.suffixPartOf === 'base') {
return 'Part of prefix';
}
if (callsign.suffixPartOf === 'prefix') {
return 'Part of secondary prefix';
}
switch (callsign.secondarySuffix) {
export function getSecondarySuffixDescription(suffix: string): string | null {
switch (suffix) {
case 'P':
return 'Portable';
case 'M':
@ -70,6 +51,7 @@ export function getSecondarySuffixDescription(callsign: CallsignData): string |
return 'Aeronautical mobile';
case 'MM':
return 'Maritime mobile';
default:
return null;
}
return null;
}

View File

@ -8,81 +8,86 @@ describe('dxccTree', () => {
});
describe('findDxcc', () => {
const s5ID = [...dxccEntities.values()].find((e) => e.primaryPrefix === 'S5')?.id;
const svID = [...dxccEntities.values()].find((e) => e.primaryPrefix === 'SV')?.id;
const svaID = [...dxccEntities.values()].find((e) => e.primaryPrefix === 'SV/a')?.id;
const sv9ID = [...dxccEntities.values()].find((e) => e.primaryPrefix === 'SV9')?.id;
test('S52KJ', () => {
const result = findDxcc('S52KJ');
expect(result?.entity).toBe(499);
expect(result?.prefixLength).toBe(2);
expect(result?.withSuffix).toBe(false);
expect(result?.entityId).toBe(s5ID);
expect(result?.matchLength).toBe(2);
expect(result?.isExact).toBe(false);
});
test('s52kj', () => {
const result = findDxcc('s52kj');
expect(result?.entity).toBe(499);
expect(result?.prefixLength).toBe(2);
expect(result?.withSuffix).toBe(false);
expect(result?.entityId).toBe(s5ID);
expect(result?.matchLength).toBe(2);
expect(result?.isExact).toBe(false);
});
test('S52KJ/P', () => {
const result = findDxcc('S52KJ/P');
expect(result?.entity).toBe(499);
expect(result?.prefixLength).toBe(2);
expect(result?.withSuffix).toBe(false);
expect(result?.entityId).toBe(s5ID);
expect(result?.matchLength).toBe(2);
expect(result?.isExact).toBe(false);
});
test('SV2AAA', () => {
const result = findDxcc('SV2AAA');
expect(result?.entity).toBe(236);
expect(result?.prefixLength).toBe(2);
expect(result?.withSuffix).toBe(false);
expect(result?.entityId).toBe(svID);
expect(result?.matchLength).toBe(2);
expect(result?.isExact).toBe(false);
});
test('SV2AAA/A', () => {
const result = findDxcc('SV2AAA/A');
expect(result?.entity).toBe(180);
expect(result?.prefixLength).toBe(2);
expect(result?.withSuffix).toBe(true);
test('SV2RSG/A', () => {
const result = findDxcc('SV2RSG/A');
expect(result?.entityId).toBe(svaID);
expect(result?.matchLength).toBe(8);
expect(result?.isExact).toBe(true);
});
test('SV2AAA/AP', () => {
const result = findDxcc('SV2AAA/AP');
expect(result?.entity).toBe(236);
expect(result?.prefixLength).toBe(2);
expect(result?.withSuffix).toBe(false);
expect(result?.entityId).toBe(svID);
expect(result?.matchLength).toBe(2);
expect(result?.isExact).toBe(false);
});
test('SV2AAA/P', () => {
const result = findDxcc('SV2AAA/P');
expect(result?.entity).toBe(236);
expect(result?.prefixLength).toBe(2);
expect(result?.withSuffix).toBe(false);
expect(result?.entityId).toBe(svID);
expect(result?.matchLength).toBe(2);
expect(result?.isExact).toBe(false);
});
test('SV/S52KJ', () => {
const result = findDxcc('SV/S52KJ');
expect(result?.entity).toBe(236);
expect(result?.prefixLength).toBe(2);
expect(result?.withSuffix).toBe(false);
expect(result?.entityId).toBe(svID);
expect(result?.matchLength).toBe(2);
expect(result?.isExact).toBe(false);
});
test('SV/S52KJ/A', () => {
const result = findDxcc('SV/S52KJ/A');
expect(result?.entity).toBe(180);
expect(result?.prefixLength).toBe(2);
expect(result?.withSuffix).toBe(true);
expect(result?.entityId).toBe(svID);
expect(result?.matchLength).toBe(2);
expect(result?.isExact).toBe(false);
});
test('SV/S52KJ/P', () => {
const result = findDxcc('SV/S52KJ/P');
expect(result?.entity).toBe(236);
expect(result?.prefixLength).toBe(2);
expect(result?.withSuffix).toBe(false);
expect(result?.entityId).toBe(svID);
expect(result?.matchLength).toBe(2);
expect(result?.isExact).toBe(false);
});
test('SV9/S52KJ/A', () => {
const result = findDxcc('SV9/S52KJ/A');
expect(result?.entity).toBe(40);
expect(result?.prefixLength).toBe(3);
expect(result?.withSuffix).toBe(false);
expect(result?.entityId).toBe(sv9ID);
expect(result?.matchLength).toBe(3);
expect(result?.isExact).toBe(false);
});
test('empty string', () => {
@ -106,8 +111,10 @@ describe('dxccEntities', () => {
expect(dxccEntities).not.toBe(null);
});
test('499', () => {
const entity = dxccEntities.get(499);
test('S5', () => {
const s5Id = [...dxccEntities.values()].find((e) => e.primaryPrefix === 'S5')!.id;
const entity = dxccEntities.get(s5Id);
expect(entity).not.toBe(undefined);
expect(entity?.name).toBe('Slovenia');
expect(entity?.cont).toBe('EU');

View File

@ -1,43 +1,50 @@
import dxccTreeFile from '../assets/dxcc-tree.txt?raw';
import dxccEntitiesFile from '../assets/dxcc-entities.json';
import { TrieNode } from './models/trie';
import type { DxccEntity } from './models/dxcc-entity';
export const dxccTree = TrieNode.decodeFromString(dxccTreeFile);
interface DxccResult {
entity: number;
prefixLength: number;
withSuffix: boolean;
entityId: number;
matchLength: number;
isExact: boolean;
}
export function findDxcc(prefix: string, startingNode: TrieNode = dxccTree): DxccResult | null {
prefix = prefix.toUpperCase();
let node = startingNode;
let entity: number | null = null;
let prefixLength = 0;
let entityId: number | null = null;
let tempPrefixLength = 0;
let matchLength = 0;
while (prefix) {
const next = node.children.get(prefix[0]);
if (prefix[0] === '/' || !next) {
const slashPos = prefix.lastIndexOf('/');
if (node.children.has('/') && slashPos > 0) {
const suffix = prefix.slice(slashPos + 1);
const res = findDxcc(suffix, node.children.get('/'));
if (res && res.prefixLength == suffix.length)
return { entity: res.entity, prefixLength, withSuffix: true };
}
if (!next) {
break;
}
node = next;
if (node.entity) entity = node.entity;
prefix = prefix.slice(1);
prefixLength++;
tempPrefixLength++;
if (node.entity) {
entityId = node.entity;
matchLength = tempPrefixLength;
}
}
if (!entity) return null;
return { entity, withSuffix: false, prefixLength };
if (node.exactEntity && !prefix) {
return {
entityId: node.exactEntity,
matchLength: tempPrefixLength,
isExact: true
};
}
if (!entityId) return null;
return { entityId, matchLength, isExact: false };
}
export const dxccEntities = new Map([...dxccEntitiesFile].map((e) => [e.entity, e]));
export const dxccEntities: Map<number, DxccEntity> = new Map(
[...dxccEntitiesFile].map((e) => [e.id, e])
);

View File

@ -1,10 +1,14 @@
export type DxccEntity = {
entity: number;
id: number;
dxcc?: number;
primaryPrefix?: string;
name: string;
cqz?: number;
ituz?: number;
cont?: string;
long?: number;
lat?: number;
timezone?: number;
start?: string;
end?: string;
};

View File

@ -4,10 +4,14 @@ import { TrieNode } from './trie';
describe('parseString', () => {
test('Basic test', () => {
const encoded = `
31-YAP-4
3=401
31-X-3
4=400
31
-YAP-4
-X-3
3
=401
4
=400
!404
`;
const root = TrieNode.decodeFromString(encoded);
@ -29,6 +33,7 @@ describe('parseString', () => {
expect(y!.id).toBe(4);
expect(y!.entity).toBe(400);
expect(y!.exactEntity).toBe(404);
expect(y!.children.size).toBe(0);
const x = root.children.get('X');

View File

@ -2,24 +2,42 @@ let nodeCounter = 0;
export class TrieNode {
public id: number;
public entity: number | null;
public exactEntity: number | null;
public children: Map<string, TrieNode>;
constructor(
public children: Map<string, TrieNode> = new Map(),
public entity: number | null = null,
id: number | null = null
) {
constructor({
id,
entity,
exactEntity,
children
}: {
id?: number | null;
entity?: number | null;
exactEntity?: number | null;
children?: Map<string, TrieNode>;
} = {}) {
if (id) this.id = id;
else this.id = ++nodeCounter;
this.entity = entity ?? null;
this.exactEntity = exactEntity ?? null;
this.children = children ?? new Map();
}
/**
* Insert a prefix into the trie.
*/
insert(prefix: string, entity: number): void {
insert(prefix: string, entity: number, isExact: boolean = false): void {
if (!prefix) {
if (this.entity && this.entity !== entity)
throw new Error(`Prefix conflict: ${this.entity} vs ${entity}`);
this.entity = entity;
if (isExact) {
if (this.exactEntity && this.exactEntity !== entity)
throw new Error(`Exact prefix conflict: ${this.exactEntity} vs ${entity}`);
this.exactEntity = entity;
} else {
if (this.entity && this.entity !== entity)
throw new Error(`Prefix conflict: ${this.entity} vs ${entity}`);
this.entity = entity;
}
return;
}
let next = this.children.get(prefix[0]);
@ -27,7 +45,7 @@ export class TrieNode {
next = new TrieNode();
this.children.set(prefix[0], next);
}
next.insert(prefix.slice(1), entity);
next.insert(prefix.slice(1), entity, isExact);
}
/**
@ -50,11 +68,40 @@ export class TrieNode {
return new Set(nodes);
}
/**
* Collapse nodes that do not cause changes.
* Returns true if node can be deleted, false otherwise.
*/
collapseNodes(currentEntity: number | null = null): boolean {
for (const [k, child] of this.children.entries()) {
if (child.collapseNodes(this.entity ?? currentEntity)) {
this.children.delete(k);
}
}
return (
this.children.size == 0 &&
(!this.entity || this.entity === currentEntity) &&
(!this.exactEntity || this.exactEntity === currentEntity)
);
}
/**
* Generate a hash for merging nodes.
*/
hash(): string {
const children = [...this.children.entries()]
.map(([k, v]) => `${k}:${v.id}`)
.sort()
.join(',');
return `${this.entity ?? ''}_${this.exactEntity ?? ''}_${children}`;
}
/**
* Checks if this node can be merged with another node.
*/
canMerge(other: TrieNode): boolean {
if (this === other) return false;
if (this.exactEntity !== other.exactEntity) return false;
if (this.entity !== other.entity) return false;
// Union set of all children keys
const l = new Set([...this.children.keys(), ...other.children.keys()]);
@ -74,9 +121,12 @@ export class TrieNode {
}
_encodeToString(): string {
const s = [];
const s = [`${this.id}`];
if (this.entity) {
s.push(`${this.id}=${this.entity}`);
s.push(`=${this.entity}`);
}
if (this.exactEntity) {
s.push(`!${this.exactEntity}`);
}
for (const c of new Set(this.children.values())) {
const chars = [];
@ -84,7 +134,7 @@ export class TrieNode {
if (v === c) chars.push(k);
}
chars.sort();
s.push(`${this.id}-${chars.join('')}-${c.id}`);
s.push(`-${chars.join('')}-${c.id}`);
}
return s.join('\n');
}
@ -98,7 +148,7 @@ export class TrieNode {
function getNode(id: number): TrieNode {
let node = nodes.get(id);
if (!node) {
node = new TrieNode(undefined, undefined, id);
node = new TrieNode({ id });
nodes.set(id, node);
// Assert root is the first node
root ??= node;
@ -106,21 +156,24 @@ export class TrieNode {
return node;
}
let currentNode: TrieNode | null = null;
for (let line of s.trim().split('\n')) {
line = line.trim();
if (!line) continue;
const parts = line.split('=');
if (parts.length === 2) {
const [id, entity] = parts;
const node = getNode(parseInt(id));
node.entity = parseInt(entity);
} else {
const [id, chars, child] = line.split('-');
const parent = getNode(parseInt(id));
if (line.startsWith('=')) {
const entity = line.slice(1);
currentNode!.entity = parseInt(entity);
} else if (line.includes('!')) {
const entity = line.slice(1);
currentNode!.exactEntity = parseInt(entity);
} else if (line.startsWith('-')) {
const [, chars, child] = line.split('-');
const childNode = getNode(parseInt(child));
for (const char of chars) {
parent.children.set(char, childNode);
currentNode!.children.set(char, childNode);
}
} else {
currentNode = getNode(parseInt(line));
}
}

View File

@ -17,10 +17,10 @@
<footer class="bg-[#444] py-3">
<div class="c flex justify-between gap-4">
<div>
By <a href="https://jkob.cc">S52KJ</a>
Website by <a href="https://jkob.cc/">S52KJ</a>
</div>
<div>
Data from <a href="https://clublog.org">ClubLog</a>
Data by <a href="https://www.country-files.com/">AD1C</a>
</div>
<div>
Source code on <a href="https://github.com/jakobkordez/call-tester">GitHub</a>

View File

@ -1,29 +1,42 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { getSecondarySuffixDescription, parseCallsign } from '$lib/callsign';
import { dxccEntities, findDxcc } from '$lib/dxcc-util';
import CallsignInput from '../components/callsign-input.svelte';
let callsign = '9a/s52kj/p';
let query = new URLSearchParams($page.url.searchParams.toString());
let callsign = query.get('c') ?? '';
$: callsignData = parseCallsign(callsign);
$: rawDxcc = findDxcc(callsign);
$: suffixPartOf = [null, 'base', 'prefix'].indexOf(callsignData?.suffixPartOf ?? null);
$: updateUrl(callsign);
function updateUrl(callsign: string) {
if (!callsign) goto('.', { keepFocus: true });
else goto(`?c=${callsign}`, { keepFocus: true });
}
function styleText(): string {
const baseClass = 'text-red-400';
const baseClass = 'text-cyan-300';
const prefixClass = 'text-blue-400';
const suffixClass = 'text-green-400';
if (!callsignData) {
const dxcc = findDxcc(callsign);
if (!dxcc) return callsign;
return `<span class="${baseClass}">${callsign.slice(0, dxcc.prefixLength)}</span>${callsign.slice(dxcc.prefixLength)}`;
return `<span class="text-cyan-300">${callsign.slice(0, dxcc.matchLength)}</span>${callsign.slice(dxcc.matchLength)}`;
}
if (rawDxcc?.isExact) {
return `<span class="text-amber-400">${callsign}</span>`;
}
const { base, basePrefix, baseSuffix, secondaryPrefix, secondarySuffix } = callsignData;
// TODO Check if base and prefix same dxcc
const suffixClass = ['text-green-400', baseClass, prefixClass][suffixPartOf];
return [
secondaryPrefix ? `<span class="${prefixClass}">${secondaryPrefix}/</span>` : '',
basePrefix ? `<span class="${baseClass}">${basePrefix}</span>${baseSuffix}` : base,
@ -40,7 +53,13 @@
<CallsignInput bind:inputText={callsign} generateStyledText={styleText} />
</div>
{#if callsignData}
{#if rawDxcc?.isExact}
<div class="data-box full">
<h2>Full match</h2>
<div class="font-mono text-2xl font-medium">{callsign}</div>
<div>{dxccEntities.get(rawDxcc.entityId)?.name ?? '?'}</div>
</div>
{:else if callsignData}
<div class="flex flex-col gap-4 md:flex-row">
{#if callsignData.prefixDxcc}
<div class="data-box prefix">
@ -57,34 +76,37 @@
</div>
{/if}
{#if callsignData.secondarySuffix}
<div class={`data-box ${['suffix', 'base', 'prefix'][suffixPartOf]}`}>
<div class="data-box suffix">
<h2>Secondary suffix</h2>
<div class="font-mono text-2xl font-medium">{callsignData.secondarySuffix}</div>
<div>{getSecondarySuffixDescription(callsignData) ?? ''}</div>
<div>{getSecondarySuffixDescription(callsignData.secondarySuffix) ?? ''}</div>
</div>
{/if}
</div>
{:else if rawDxcc}
<div class="data-box base">
<h2>Prefix</h2>
<div class="font-mono text-2xl font-medium">{callsign.slice(0, rawDxcc.prefixLength)}</div>
<div>{dxccEntities.get(rawDxcc.entity)?.name ?? '?'}</div>
<div class="font-mono text-2xl font-medium">{callsign.slice(0, rawDxcc.matchLength)}</div>
<div>{dxccEntities.get(rawDxcc.entityId)?.name ?? '?'}</div>
</div>
{/if}
</div>
<style lang="postcss">
.data-box {
@apply flex-1 rounded-xl p-4 text-center;
@apply mx-auto w-full max-w-[50%] flex-1 rounded-xl p-4 text-center;
}
.data-box > h2 {
@apply text-sm;
}
.data-box.full {
@apply bg-amber-600/40;
}
.data-box.prefix {
@apply bg-blue-600/40;
}
.data-box.base {
@apply bg-red-600/40;
@apply bg-cyan-600/40;
}
.data-box.suffix {
@apply bg-green-600/40;