mirror of
https://github.com/jakobkordez/call-tester.git
synced 2025-05-30 07:30:27 +00:00
Changed to CTY database
This commit is contained in:
parent
4db1c4e1dc
commit
49273955f8
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@ -1,4 +1,4 @@
|
||||
{
|
||||
"vite.autoStart": false,
|
||||
"cSpell.words": ["callsign", "dxcc", "adif", "graphviz", "clublog"]
|
||||
"cSpell.words": ["adif", "callsign", "clublog", "dxcc", "graphviz", "ituz", "timez"]
|
||||
}
|
||||
|
18
README.md
18
README.md
@ -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:
|
||||
|
@ -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
150
scripts/cty-csv-parser.ts
Normal 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
148
scripts/cty-dat-parser.ts
Normal 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
29902
src/assets/dxcc-tree.txt
29902
src/assets/dxcc-tree.txt
File diff suppressed because it is too large
Load Diff
@ -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);
|
||||
});
|
||||
});
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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');
|
||||
|
@ -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])
|
||||
);
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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');
|
||||
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
|
Loading…
x
Reference in New Issue
Block a user