@finbheara/domain 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +198 -2
- package/dist/age.js +420 -0
- package/dist/ceili.js +15 -0
- package/dist/chunks/place-7gqecaxd.js +201 -0
- package/dist/chunks/place-g6yw4cq6.js +134 -0
- package/dist/chunks/place-kr42294h.js +267 -0
- package/dist/chunks/place-qbp3xfb4.js +7412 -0
- package/dist/clrg-regions.js +603 -0
- package/dist/dance-types.js +14 -0
- package/dist/dance.js +41 -0
- package/dist/place.js +36 -0
- package/dist/school.js +2240 -0
- package/dist/tune.js +23 -0
- package/dist/types/age-types.d.ts +31 -0
- package/dist/types/age.d.ts +84 -0
- package/dist/types/ceili.d.ts +53 -0
- package/dist/types/clrg-regions.d.ts +31 -0
- package/dist/types/dance-match.d.ts +28 -0
- package/dist/types/dance-types.d.ts +9 -0
- package/dist/types/dance.d.ts +14 -0
- package/dist/types/place-tree.d.ts +4 -0
- package/dist/types/place-types.d.ts +24 -0
- package/dist/types/place.d.ts +98 -0
- package/dist/types/school-compare.d.ts +36 -0
- package/dist/types/school-exception-vocabulary.d.ts +23 -0
- package/dist/types/school-fluff-vocabulary.d.ts +16 -0
- package/dist/types/school-fused-prefix.d.ts +1 -0
- package/dist/types/school-identity.d.ts +9 -0
- package/dist/types/school-institution-vocabulary.d.ts +20 -0
- package/dist/types/school-location.d.ts +16 -0
- package/dist/types/school-match-key.d.ts +5 -0
- package/dist/types/school-name.d.ts +82 -0
- package/dist/types/school-tokens.d.ts +20 -0
- package/dist/types/school.d.ts +9 -0
- package/dist/types/tune.d.ts +63 -0
- package/package.json +69 -5
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Finbheara Systems
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,199 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @finbheara/domain
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Places, CLRG regions, school names, ages and dances as they are actually printed: read,
|
|
4
|
+
compared and told apart. A domain model downstream of
|
|
5
|
+
[`@finbheara/names`](https://www.npmjs.com/package/@finbheara/names), which reads the
|
|
6
|
+
person names.
|
|
7
|
+
|
|
8
|
+
- **Places**: a print in, the set of place-tree nodes it may mean out. A country, a state or
|
|
9
|
+
province, a county, a city, or a CLRG region, with a relation between any two readings.
|
|
10
|
+
- **CLRG regions**: the CLRG's dance regions and their Oireachtas codes, and the region a
|
|
11
|
+
location phrase falls in.
|
|
12
|
+
- **School names**: a printed school name in, a `SchoolName` out, with a display form, a
|
|
13
|
+
filing key and a verdict on whether two prints may be one school.
|
|
14
|
+
- **Ages**: an age group (`U12`, `Over 21`, `13-14`) or a year of birth in, an `Age` out: a
|
|
15
|
+
closed range of birth years, exact when it is one year, with `fitsIn`, `overlaps` and a
|
|
16
|
+
team's `averageAge`.
|
|
17
|
+
- **Dances**: a spoken or printed set dance or céilí dance in, the dances it may name out,
|
|
18
|
+
with tempo rules and hand counts.
|
|
19
|
+
|
|
20
|
+
It was built on the stage announcements and results sheets of Irish dance competitions,
|
|
21
|
+
where one school arrives as `Ardvarna Academy of Irish Dance (Calgary, AB)` and as
|
|
22
|
+
`ARDVARNA ACADEMY - Calgary, Alberta`.
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
npm install @finbheara/domain
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
ESM, Node ≥ 22 or Bun ≥ 1.4.2. MIT. Not affiliated with or endorsed by any dance commission.
|
|
29
|
+
|
|
30
|
+
## Entry points
|
|
31
|
+
|
|
32
|
+
| import | what |
|
|
33
|
+
|---|---|
|
|
34
|
+
| `@finbheara/domain/place` | `place()`, `createPlaceReader`, `placeRelation`, the `Place` types |
|
|
35
|
+
| `@finbheara/domain/clrg-regions` | `REGIONS`, `regionOf`, `resolvePhrase` |
|
|
36
|
+
| `@finbheara/domain/school` | `createSchoolNameReader`, `DEFAULT_SCHOOL_NAME_READER`, `compareSchoolNames`, `foldSchoolName` |
|
|
37
|
+
| `@finbheara/domain/tune` | `tune()`, `tuneRelation`, `tempoLegal`, `settingAt`, `TUNE_NODES`: the CLRG set dance list |
|
|
38
|
+
| `@finbheara/domain/ceili` | `ceili()`, `ceiliRelation`, `CEILI_NODES`: the céilí handbook dances |
|
|
39
|
+
| `@finbheara/domain/dance` | `dance()`: a print read as a tune and a céilí dance, narrowed by its round |
|
|
40
|
+
| `@finbheara/domain/age` | `Age.from`, `Age.fromYOB`, `ageRelation`, `canOverlap`, `averageAge`, `isAveraged`, the age constants |
|
|
41
|
+
| `@finbheara/domain/dance-types` | `danceTypes()`, `DANCE_TYPES`, `rhythmOfTime`, and the shared `DanceMatch`/`DanceRelation`/`SetRhythm`/`TimeSignature` types |
|
|
42
|
+
|
|
43
|
+
The library does no I/O and installs nothing process-wide. Every reader has a default and a
|
|
44
|
+
factory: build your own with options when the default is not what you want.
|
|
45
|
+
|
|
46
|
+
## Read a place
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import { place, placeRelation } from "@finbheara/domain/place";
|
|
50
|
+
|
|
51
|
+
const p = place("Pennsylvania, Mid-Atlantic Region");
|
|
52
|
+
p.arm; // "resolved"
|
|
53
|
+
p.pl2; // "USA-PA"
|
|
54
|
+
p.nodes.map((n) => n.id); // ["USA", "USA-PA", "IDTANA-MARO"]
|
|
55
|
+
p.country; // "USA"
|
|
56
|
+
p.region; // "IDTANA-MARO"
|
|
57
|
+
|
|
58
|
+
place("Calgary, AB").pl2; // "CAN-AB/calgary"
|
|
59
|
+
place("Co. Cork").region; // "MUNSTER"
|
|
60
|
+
place("CA").arm; // "ambiguous": California or Canada
|
|
61
|
+
place("Dublin").arm; // "ambiguous": Ireland, California or Ohio
|
|
62
|
+
|
|
63
|
+
placeRelation(place("Calgary, AB"), place("Western Canada")); // "contains"
|
|
64
|
+
placeRelation(place("Virginia"), place("Ontario")); // "conflict"
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
A `Place` is one of four arms: `resolved`, `plural` (a print naming more than one place),
|
|
68
|
+
`ambiguous` (one print, several readings) and `unread`. Only `conflict` between two readings
|
|
69
|
+
says they cannot be one place; `same`, `contains` and `overlaps` do not.
|
|
70
|
+
|
|
71
|
+
## Find a CLRG region
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
import { REGIONS, regionOf, resolvePhrase } from "@finbheara/domain/clrg-regions";
|
|
75
|
+
|
|
76
|
+
regionOf("Pennsylvania"); // "MARO"
|
|
77
|
+
REGIONS.MARO; // "Mid-Atlantic Region USA"
|
|
78
|
+
resolvePhrase("Pennsylvania, Mid-Atlantic Region"); // ["MARO"]
|
|
79
|
+
resolvePhrase("Calgary in Western Canada"); // ["WCRO"]
|
|
80
|
+
resolvePhrase("Derry in Ireland"); // ["ULSTER"]
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`resolvePhrase` answers every region a phrase supports, and `[]` where it supports none. A
|
|
84
|
+
token that names places in two regions resolves only with a co-token that settles it.
|
|
85
|
+
|
|
86
|
+
## Read a school name
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
import { DEFAULT_SCHOOL_NAME_READER, compareSchoolNames } from "@finbheara/domain/school";
|
|
90
|
+
|
|
91
|
+
const reader = DEFAULT_SCHOOL_NAME_READER;
|
|
92
|
+
const s = reader.schoolName("Ardvarna Academy of Irish Dance (Calgary, AB)");
|
|
93
|
+
s.sn2; // "Ardvarna Academy (CAN-AB/calgary)" display form
|
|
94
|
+
s.sn3; // "ARDVARNA ACADEMY (CAN-AB/calgary)" filing key
|
|
95
|
+
s.institution; // "academy"
|
|
96
|
+
s.location?.pl3; // "CAN-AB/calgary"
|
|
97
|
+
s.isSchool(); // true
|
|
98
|
+
|
|
99
|
+
reader.schoolName("Scoil Rince Glenmorvan").sn2; // "S.R. Glenmorvan"
|
|
100
|
+
reader.schoolName("N/A").refusal?.reason; // "placeholder"
|
|
101
|
+
|
|
102
|
+
compareSchoolNames("Ardvarna Academy of Irish Dance (Calgary, AB)",
|
|
103
|
+
"ARDVARNA ACADEMY - Calgary, Alberta", reader).verdict; // "CANDIDATE"
|
|
104
|
+
compareSchoolNames("Ardvarna Academy (Calgary, AB)",
|
|
105
|
+
"Ardvarna Academy (Ontario)", reader).verdict; // "BLOCKED"
|
|
106
|
+
compareSchoolNames("Ardvarna Academy",
|
|
107
|
+
"Ardvarna Academy (Ontario)", reader).verdict; // "AMBIGUOUS"
|
|
108
|
+
compareSchoolNames("Ardvarna Academy", "Glenmorvan Academy", reader).verdict; // "DIFFERENT"
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The verdicts: `CANDIDATE` (same name, compatible qualifiers), `BLOCKED` (same name,
|
|
112
|
+
contradicting qualifiers), `AMBIGUOUS` (needs context) and `DIFFERENT`. A verdict is a name
|
|
113
|
+
reading and never an identity: two schools can share a name.
|
|
114
|
+
|
|
115
|
+
`DEFAULT_SCHOOL_NAME_READER` is the generic reader: no lexicon, the shipped registers, frozen.
|
|
116
|
+
Nothing replaces it, and the package holds no other. `compareSchoolNames` and
|
|
117
|
+
`schoolPrintSplit` take the reader as an argument, always, so a print is never read by a
|
|
118
|
+
reader you did not choose. A reader with a lexicon (for the surnames a school is named after),
|
|
119
|
+
your own exception and refusal registers, or your own garbage is one you build:
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
import { compareSchoolNames, createSchoolNameReader } from "@finbheara/domain/school";
|
|
123
|
+
|
|
124
|
+
const reader = createSchoolNameReader({ lexicon: (key) => myLexicon.get(key) });
|
|
125
|
+
compareSchoolNames("Ardvarna Academy", "Ardvarna Academy (Ontario)", reader);
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Your own garbage
|
|
129
|
+
|
|
130
|
+
The shipped refusals are generic: a card number, keyboard filler, a run with no letter in it,
|
|
131
|
+
the shared placeholder list of `@finbheara/names` (`N/A`, `No School`, ...). What YOUR data
|
|
132
|
+
prints in a school field and is not a school, such as an entry system's own "school not
|
|
133
|
+
listed" instruction, a test entry, or a note typed into the wrong field, is yours to declare.
|
|
134
|
+
Pass it as `garbage`, the same idea as `phraseClassifier(lexicon, { garbageVocab })` in
|
|
135
|
+
`@finbheara/names`:
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
import { createSchoolNameReader } from "@finbheara/domain/school";
|
|
139
|
+
|
|
140
|
+
const reader = createSchoolNameReader({
|
|
141
|
+
garbage: [
|
|
142
|
+
{ pattern: "-- my school is not on this list --", kind: "exact", reason: "placeholder" },
|
|
143
|
+
{ pattern: "demo school ", kind: "prefix", reason: "placeholder" },
|
|
144
|
+
{ pattern: "^sister of .+$", kind: "regex", reason: "not-a-name" },
|
|
145
|
+
],
|
|
146
|
+
});
|
|
147
|
+
reader.schoolName("-- My School Is Not On This List --").refusal?.reason; // "placeholder"
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
A row's `pattern` is matched against the print with its case lowered, its accents and
|
|
151
|
+
invisible characters dropped and its whitespace collapsed; a `regex` is compiled with the `i`
|
|
152
|
+
flag and anchors itself. Your rows are asked before the shipped ones and the first match
|
|
153
|
+
answers.
|
|
154
|
+
|
|
155
|
+
## Read an age
|
|
156
|
+
|
|
157
|
+
Ages are as of 1 January of the competition year. An `Age` is a closed range of birth years,
|
|
158
|
+
whatever it was read from, so a year of birth and a one-year age group are the same thing.
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
import { Age, averageAge, canOverlap } from "@finbheara/domain/age";
|
|
162
|
+
|
|
163
|
+
const dancer = Age.fromYOB(2014);
|
|
164
|
+
dancer.isExact(); // true
|
|
165
|
+
|
|
166
|
+
const u12 = Age.from("U12", { year: 2026 });
|
|
167
|
+
u12.birthYears(); // { from: 2014, to: 2023 }
|
|
168
|
+
dancer.fitsIn(u12); // true
|
|
169
|
+
|
|
170
|
+
// With the age groups the event offered, U12 is one birth year, so it is exact.
|
|
171
|
+
const ladder = { caps: [11, 12, 13], overs: [], ranges: [] };
|
|
172
|
+
Age.from("U12", { year: 2026, ladder }).isExact(); // true
|
|
173
|
+
|
|
174
|
+
Age.fromYOB(1999).fitsIn(Age.from("U18", { year: 2025 })); // false
|
|
175
|
+
Age.fromYOB(1999).fitsIn(Age.from("Over 21", { year: 2026, top: true })); // true
|
|
176
|
+
|
|
177
|
+
canOverlap(u12, Age.from("U14", { year: 2026 }), dancer); // true
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
A team's age is the average of its dancers' ages, rounded to two decimals. `averageAge`
|
|
181
|
+
takes exact ages only and returns an `AverageAge`, a separate type that refuses, by name,
|
|
182
|
+
any use that would treat it as one dancer's age.
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
const team = averageAge([1990, 1985, 1999, 1988].map((y) => Age.fromYOB(y)));
|
|
186
|
+
team.meanAge(2026); // 34.5
|
|
187
|
+
team.fitsIn(Age.from("U40", { year: 2026 })); // true
|
|
188
|
+
|
|
189
|
+
averageAge([u12]); // throws AgeMisuseError "not-exact"
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## Known gaps
|
|
193
|
+
|
|
194
|
+
- `place()` reads `"Derry in Ireland"` and `"Calgary in Western Canada"` as `unread`: it does
|
|
195
|
+
not split a phrase at `in`. `resolvePhrase` finds their regions.
|
|
196
|
+
|
|
197
|
+
## Licence
|
|
198
|
+
|
|
199
|
+
MIT, Copyright (c) 2026 Finbheara Systems.
|
package/dist/age.js
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
var YOUNGEST_FLOOR = 0;
|
|
2
|
+
var ADULT_FLOOR = 18;
|
|
3
|
+
var MAJORS_FLOOR_YEARS = 3;
|
|
4
|
+
var UNDER_CAP = Object.freeze({ plain: 2, exceptional: 4 });
|
|
5
|
+
var UNDER_CAP_MAX_TOP = 21;
|
|
6
|
+
var YOUNG_FLOOR = Object.freeze({ hard: 2, soft: 4 });
|
|
7
|
+
var MIN_MAJOR_AGE = 4;
|
|
8
|
+
var MIN_FEIS_AGE = 3;
|
|
9
|
+
var DANCE_UP_YEARS = 1;
|
|
10
|
+
var ageOnJanuary1 = (year, born) => year - born - 1;
|
|
11
|
+
var yearsSinceBirth = (year, born) => year - born;
|
|
12
|
+
var UNDER = /^(?:u|under\s*)(\d+)$/i;
|
|
13
|
+
var OVER = /^(?:o|over\s*)(\d+)$/i;
|
|
14
|
+
var RANGE = /^u?(\d+)\s*[-–]\s*(\d+)$/i;
|
|
15
|
+
var ADULT_WORD = /^(?:adults?|ad|advanced|masters?)$/i;
|
|
16
|
+
var SENIOR_WORD = /^seniors?$/i;
|
|
17
|
+
var NO_LADDER = Object.freeze({ caps: [], overs: [], ranges: [] });
|
|
18
|
+
function maxWhere(xs, pred) {
|
|
19
|
+
let best;
|
|
20
|
+
for (const x of xs)
|
|
21
|
+
if (pred(x) && (best === undefined || x > best))
|
|
22
|
+
best = x;
|
|
23
|
+
return best;
|
|
24
|
+
}
|
|
25
|
+
function minWhere(xs, pred) {
|
|
26
|
+
let best;
|
|
27
|
+
for (const x of xs)
|
|
28
|
+
if (pred(x) && (best === undefined || x < best))
|
|
29
|
+
best = x;
|
|
30
|
+
return best;
|
|
31
|
+
}
|
|
32
|
+
function readRange(a, b, ladder) {
|
|
33
|
+
if (!Number.isFinite(a) || !Number.isFinite(b) || b <= a)
|
|
34
|
+
return {
|
|
35
|
+
kind: "range",
|
|
36
|
+
interval: null,
|
|
37
|
+
refusal: "backwards-range",
|
|
38
|
+
clauses: [],
|
|
39
|
+
parts: [a, b]
|
|
40
|
+
};
|
|
41
|
+
const overlapping = ladder.ranges.includes(a) || ladder.ranges.includes(b + 1);
|
|
42
|
+
const bottom = !ladder.caps.some((c) => c <= a);
|
|
43
|
+
const atAdultFloor = bottom && a >= ADULT_FLOOR;
|
|
44
|
+
const youngest = bottom && !atAdultFloor;
|
|
45
|
+
const onRung = ladder.caps.includes(a);
|
|
46
|
+
const floor = overlapping || atAdultFloor || onRung ? a : Math.max(YOUNGEST_FLOOR, a - 1);
|
|
47
|
+
const clauses = [overlapping ? "range-overlapping" : "range-inclusive"];
|
|
48
|
+
if (youngest)
|
|
49
|
+
clauses.push("youngest-absorbs");
|
|
50
|
+
else if (atAdultFloor)
|
|
51
|
+
clauses.push("range-adult-floor");
|
|
52
|
+
else if (!overlapping)
|
|
53
|
+
clauses.push(onRung ? "range-floor-on-rung" : "range-floor-widened");
|
|
54
|
+
return {
|
|
55
|
+
kind: "range",
|
|
56
|
+
interval: { lo: youngest ? YOUNGEST_FLOOR : floor, hi: overlapping ? b : b + 1 },
|
|
57
|
+
refusal: null,
|
|
58
|
+
clauses,
|
|
59
|
+
parts: [a, b]
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function read(token, ladder, top) {
|
|
63
|
+
const r = RANGE.exec(token);
|
|
64
|
+
if (r !== null)
|
|
65
|
+
return readRange(Number(r[1]), Number(r[2]), ladder);
|
|
66
|
+
if (ADULT_WORD.test(token))
|
|
67
|
+
return {
|
|
68
|
+
kind: "adult",
|
|
69
|
+
interval: { lo: ADULT_FLOOR, hi: Number.POSITIVE_INFINITY },
|
|
70
|
+
refusal: null,
|
|
71
|
+
clauses: ["and-over-no-ceiling"],
|
|
72
|
+
parts: []
|
|
73
|
+
};
|
|
74
|
+
if (SENIOR_WORD.test(token)) {
|
|
75
|
+
const top = maxWhere(ladder.caps, () => true);
|
|
76
|
+
return top === undefined ? { kind: "senior", interval: null, refusal: "senior-without-ladder", clauses: [], parts: [] } : {
|
|
77
|
+
kind: "senior",
|
|
78
|
+
interval: { lo: top, hi: Number.POSITIVE_INFINITY },
|
|
79
|
+
refusal: null,
|
|
80
|
+
clauses: ["and-over-no-ceiling"],
|
|
81
|
+
parts: []
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
const u = UNDER.exec(token);
|
|
85
|
+
if (u !== null) {
|
|
86
|
+
const n = Number(u[1]);
|
|
87
|
+
const below = maxWhere(ladder.caps, (c) => c < n);
|
|
88
|
+
return {
|
|
89
|
+
kind: "under",
|
|
90
|
+
interval: { lo: below ?? YOUNGEST_FLOOR, hi: n },
|
|
91
|
+
refusal: null,
|
|
92
|
+
clauses: [below === undefined ? "youngest-absorbs" : "under-floored-on-rung"],
|
|
93
|
+
parts: [n]
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
const o = OVER.exec(token);
|
|
97
|
+
if (o !== null) {
|
|
98
|
+
const n = Number(o[1]);
|
|
99
|
+
const above = top ? undefined : minWhere(ladder.overs, (c) => c > n);
|
|
100
|
+
if (above === undefined)
|
|
101
|
+
return {
|
|
102
|
+
kind: "over",
|
|
103
|
+
interval: { lo: n, hi: Number.POSITIVE_INFINITY },
|
|
104
|
+
refusal: null,
|
|
105
|
+
clauses: ["and-over-no-ceiling"],
|
|
106
|
+
parts: [n]
|
|
107
|
+
};
|
|
108
|
+
const gapped = ladder.caps.length > 0 && !ladder.caps.some((c) => c > n && c < above);
|
|
109
|
+
return {
|
|
110
|
+
kind: "over",
|
|
111
|
+
interval: { lo: n, hi: gapped ? Number.POSITIVE_INFINITY : above },
|
|
112
|
+
refusal: null,
|
|
113
|
+
clauses: [gapped ? "over-ceiling-refused-gap" : "over-ceiling-from-next-over"],
|
|
114
|
+
parts: [n]
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return { kind: null, interval: null, refusal: "not-a-band", clauses: [], parts: [] };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
class AgeMisuseError extends Error {
|
|
121
|
+
misuse;
|
|
122
|
+
constructor(misuse, detail) {
|
|
123
|
+
super(`age: ${misuse}: ${detail}`);
|
|
124
|
+
this.name = "AgeMisuseError";
|
|
125
|
+
this.misuse = misuse;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
var display = (iv) => `[${iv.lo},${iv.hi === Number.POSITIVE_INFINITY ? "∞" : iv.hi})`;
|
|
129
|
+
var currentYear = () => new Date().getUTCFullYear();
|
|
130
|
+
function withRelations(a) {
|
|
131
|
+
const self = a;
|
|
132
|
+
return Object.assign(self, {
|
|
133
|
+
fitsIn: (container) => {
|
|
134
|
+
if (isAveraged(container))
|
|
135
|
+
throw new AgeMisuseError("average-as-container", `${self.ag2} cannot fit inside an average age (${String(container)}); an average is not a dancer`);
|
|
136
|
+
const r = ageRelation(self, container);
|
|
137
|
+
return r.relation === "same" || r.reason === "b-contains-a";
|
|
138
|
+
},
|
|
139
|
+
overlaps: (other) => {
|
|
140
|
+
const r = ageRelation(self, other).relation;
|
|
141
|
+
return r === "same" || r === "contains" || r === "overlaps";
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
function fromPrint(print, context = {}) {
|
|
146
|
+
const ladder = context.ladder ?? NO_LADDER;
|
|
147
|
+
const year = context.year === undefined ? currentYear() : context.year;
|
|
148
|
+
const ag1 = print.trim().toLowerCase();
|
|
149
|
+
const reading = ag1 === "" ? { kind: null, interval: null, refusal: null, clauses: [], parts: [] } : read(print.trim(), ladder, context.top === true);
|
|
150
|
+
const iv = reading.interval;
|
|
151
|
+
const arm = ag1 === "" ? "empty" : iv === null ? "open" : "band";
|
|
152
|
+
const ag2 = iv !== null ? display(iv) : ag1 === "" ? "" : `${ag1}?`;
|
|
153
|
+
const source = sourceOf(reading);
|
|
154
|
+
return withRelations({
|
|
155
|
+
[Symbol.toStringTag]: "Age",
|
|
156
|
+
toString: () => ag2,
|
|
157
|
+
arm,
|
|
158
|
+
kind: reading.kind,
|
|
159
|
+
ag0: print,
|
|
160
|
+
ag1,
|
|
161
|
+
ag2,
|
|
162
|
+
parts: reading.parts,
|
|
163
|
+
length: ag2.length,
|
|
164
|
+
refusal: reading.refusal,
|
|
165
|
+
source,
|
|
166
|
+
clauses: reading.clauses,
|
|
167
|
+
ladder,
|
|
168
|
+
year,
|
|
169
|
+
isEmpty: () => arm === "empty",
|
|
170
|
+
isAge: () => iv !== null,
|
|
171
|
+
isExact: () => {
|
|
172
|
+
if (iv === null || year === null)
|
|
173
|
+
return false;
|
|
174
|
+
const b = birthYearsOfInterval(iv, year);
|
|
175
|
+
return b.from !== null && b.from === b.to;
|
|
176
|
+
},
|
|
177
|
+
isAndOver: () => iv !== null && iv.hi === Number.POSITIVE_INFINITY,
|
|
178
|
+
isAdult: () => reading.kind === "adult",
|
|
179
|
+
interval: () => iv,
|
|
180
|
+
birthYears: (y) => {
|
|
181
|
+
const at = y ?? year;
|
|
182
|
+
if (at === null)
|
|
183
|
+
throw new Error(`Age.from(${JSON.stringify(print)}): birthYears needs a competition year`);
|
|
184
|
+
return birthYearsOfInterval(iv, at);
|
|
185
|
+
},
|
|
186
|
+
floorObserved: () => reading.kind !== "under" || iv === null ? null : reading.clauses.includes("under-floored-on-rung")
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
function fromYOB(from, to = from) {
|
|
190
|
+
for (const y of [from, to])
|
|
191
|
+
if (y !== null && !Number.isInteger(y))
|
|
192
|
+
throw new RangeError(`Age.fromYOB(${from}, ${to}): a year of birth is a whole number`);
|
|
193
|
+
if (from === null && to === null)
|
|
194
|
+
throw new RangeError("Age.fromYOB(null, null): at least one end is a year");
|
|
195
|
+
if (from !== null && to !== null && from > to)
|
|
196
|
+
throw new RangeError(`Age.fromYOB(${from}, ${to}): the earliest year comes first`);
|
|
197
|
+
const exact = from !== null && from === to;
|
|
198
|
+
const ag0 = exact ? String(from) : `${from ?? ""}..${to ?? ""}`;
|
|
199
|
+
const ag2 = `b${ag0}`;
|
|
200
|
+
const years = Object.freeze({ from, to });
|
|
201
|
+
return withRelations({
|
|
202
|
+
[Symbol.toStringTag]: "Age",
|
|
203
|
+
toString: () => ag2,
|
|
204
|
+
arm: "yob",
|
|
205
|
+
kind: "yob",
|
|
206
|
+
ag0,
|
|
207
|
+
ag1: ag0,
|
|
208
|
+
ag2,
|
|
209
|
+
parts: exact ? [from] : [from, to].filter((y) => y !== null),
|
|
210
|
+
length: ag2.length,
|
|
211
|
+
refusal: null,
|
|
212
|
+
source: "printed-yob",
|
|
213
|
+
clauses: [],
|
|
214
|
+
ladder: NO_LADDER,
|
|
215
|
+
year: null,
|
|
216
|
+
isEmpty: () => false,
|
|
217
|
+
isAge: () => true,
|
|
218
|
+
isExact: () => exact,
|
|
219
|
+
isAndOver: () => from === null,
|
|
220
|
+
isAdult: () => false,
|
|
221
|
+
interval: () => null,
|
|
222
|
+
birthYears: () => years,
|
|
223
|
+
floorObserved: () => null
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
var Age = Object.freeze({ from: fromPrint, fromYOB });
|
|
227
|
+
function sourceOf(r) {
|
|
228
|
+
const iv = r.interval;
|
|
229
|
+
if (iv === null)
|
|
230
|
+
return "open";
|
|
231
|
+
switch (r.kind) {
|
|
232
|
+
case "range":
|
|
233
|
+
return "printed-range";
|
|
234
|
+
case "over":
|
|
235
|
+
return iv.hi === Number.POSITIVE_INFINITY ? "printed-band" : "sibling-narrowed";
|
|
236
|
+
case "adult":
|
|
237
|
+
case "senior":
|
|
238
|
+
return "printed-band";
|
|
239
|
+
case "under":
|
|
240
|
+
return iv.lo > YOUNGEST_FLOOR ? "sibling-narrowed" : "printed-band";
|
|
241
|
+
default:
|
|
242
|
+
return "open";
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
function birthYearsOfInterval(iv, year) {
|
|
246
|
+
if (iv === null)
|
|
247
|
+
return { from: null, to: null };
|
|
248
|
+
return {
|
|
249
|
+
from: iv.hi === Number.POSITIVE_INFINITY ? null : year - iv.hi,
|
|
250
|
+
to: Math.min(year - iv.lo - 1, year - MAJORS_FLOOR_YEARS)
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
var inBirthYears = (a) => a.arm === "yob" || a.year !== null;
|
|
254
|
+
function spanOf(a) {
|
|
255
|
+
if (!a.isAge())
|
|
256
|
+
return null;
|
|
257
|
+
if (inBirthYears(a)) {
|
|
258
|
+
const b = a.birthYears();
|
|
259
|
+
return {
|
|
260
|
+
lo: b.from ?? Number.NEGATIVE_INFINITY,
|
|
261
|
+
hi: b.to ?? Number.POSITIVE_INFINITY
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
const iv = a.interval();
|
|
265
|
+
return { lo: iv.lo, hi: iv.hi - 1 };
|
|
266
|
+
}
|
|
267
|
+
function ageRelation(a, b) {
|
|
268
|
+
for (const x of [a, b])
|
|
269
|
+
if (isAveraged(x))
|
|
270
|
+
throw new AgeMisuseError("average-in-relation", `${String(x)} is an average, not a dancer's birth years; ask its fitsIn`);
|
|
271
|
+
if (inBirthYears(a) !== inBirthYears(b))
|
|
272
|
+
throw new Error("ageRelation: one reading is in birth years and the other has no competition year; read both with a year, or neither");
|
|
273
|
+
if (!a.isAge())
|
|
274
|
+
return { relation: "silent", reason: "a-not-a-band" };
|
|
275
|
+
if (!b.isAge())
|
|
276
|
+
return { relation: "silent", reason: "b-not-a-band" };
|
|
277
|
+
const sa = spanOf(a);
|
|
278
|
+
const sb = spanOf(b);
|
|
279
|
+
const years = inBirthYears(a);
|
|
280
|
+
if (sa.hi < sb.lo)
|
|
281
|
+
return { relation: "disjoint", reason: years ? "a-older" : "a-younger" };
|
|
282
|
+
if (sb.hi < sa.lo)
|
|
283
|
+
return { relation: "disjoint", reason: years ? "a-younger" : "a-older" };
|
|
284
|
+
if (sa.lo === sb.lo && sa.hi === sb.hi)
|
|
285
|
+
return { relation: "same", reason: "equal" };
|
|
286
|
+
if (sa.lo <= sb.lo && sb.hi <= sa.hi)
|
|
287
|
+
return { relation: "contains", reason: "a-contains-b" };
|
|
288
|
+
if (sb.lo <= sa.lo && sa.hi <= sb.hi)
|
|
289
|
+
return { relation: "contains", reason: "b-contains-a" };
|
|
290
|
+
return { relation: "overlaps", reason: "partial" };
|
|
291
|
+
}
|
|
292
|
+
function canOverlap(...ages) {
|
|
293
|
+
for (let i = 0;i < ages.length; i++)
|
|
294
|
+
for (let j = i + 1;j < ages.length; j++)
|
|
295
|
+
if (ageRelation(ages[i], ages[j]).relation === "disjoint")
|
|
296
|
+
return false;
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
var AVERAGE_AGE_DECIMALS = 2;
|
|
300
|
+
var HUNDREDTHS = 10 ** AVERAGE_AGE_DECIMALS;
|
|
301
|
+
function roundedMeanHundredths(k, n) {
|
|
302
|
+
if (!Number.isInteger(k) || !Number.isInteger(n) || n <= 0)
|
|
303
|
+
throw new RangeError(`roundedMeanHundredths(${k}, ${n}): whole numbers, n > 0`);
|
|
304
|
+
return Math.floor((2 * HUNDREDTHS * k + n) / (2 * n));
|
|
305
|
+
}
|
|
306
|
+
function isAveraged(a) {
|
|
307
|
+
return a[Symbol.toStringTag] === "AverageAge";
|
|
308
|
+
}
|
|
309
|
+
function averageAge(ages) {
|
|
310
|
+
if (ages.length === 0)
|
|
311
|
+
throw new AgeMisuseError("empty-average", "averageAge needs at least one exact age");
|
|
312
|
+
let sum = 0;
|
|
313
|
+
for (const [i, a] of ages.entries()) {
|
|
314
|
+
if (isAveraged(a))
|
|
315
|
+
throw new AgeMisuseError("average-of-averages", `input ${i} (${String(a)}) is already an average; average the members instead`);
|
|
316
|
+
if (!a.isExact())
|
|
317
|
+
throw new AgeMisuseError("not-exact", `input ${i} (${String(a)}) is not one birth year; an average takes exact ages only`);
|
|
318
|
+
sum += a.birthYears().from;
|
|
319
|
+
}
|
|
320
|
+
const n = ages.length;
|
|
321
|
+
const members = Object.freeze([...ages]);
|
|
322
|
+
const hundredths = (year) => roundedMeanHundredths(n * (year - 1) - sum, n);
|
|
323
|
+
const self = {
|
|
324
|
+
[Symbol.toStringTag]: "AverageAge",
|
|
325
|
+
provenance: `average of ${n}`,
|
|
326
|
+
members,
|
|
327
|
+
n,
|
|
328
|
+
birthYearSum: sum,
|
|
329
|
+
meanBirthYear: sum / n,
|
|
330
|
+
toString: () => `avg(${n}):b${sum / n}`,
|
|
331
|
+
isExact: () => false,
|
|
332
|
+
meanAgeHundredths: hundredths,
|
|
333
|
+
meanAge: (year) => hundredths(year) / HUNDREDTHS,
|
|
334
|
+
fitsIn: (container) => {
|
|
335
|
+
if (isAveraged(container))
|
|
336
|
+
throw new AgeMisuseError("average-as-container", `nothing fits inside an average age (${String(container)})`);
|
|
337
|
+
if (container.year === null)
|
|
338
|
+
throw new AgeMisuseError("average-container-no-year", `${String(container)} has no competition year to take the average age in`);
|
|
339
|
+
const iv = container.interval();
|
|
340
|
+
if (iv === null)
|
|
341
|
+
return false;
|
|
342
|
+
const h = hundredths(container.year);
|
|
343
|
+
return iv.lo * HUNDREDTHS <= h && h < iv.hi * HUNDREDTHS;
|
|
344
|
+
},
|
|
345
|
+
birthYears: () => {
|
|
346
|
+
throw new AgeMisuseError("average-birth-years", `an average of ${n} has no year of birth; read meanBirthYear, which is not a dancer's`);
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
return self;
|
|
350
|
+
}
|
|
351
|
+
function birthYearsDisjoint(a, b) {
|
|
352
|
+
if (a.to !== null && b.from !== null && a.to < b.from)
|
|
353
|
+
return true;
|
|
354
|
+
if (b.to !== null && a.from !== null && b.to < a.from)
|
|
355
|
+
return true;
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
358
|
+
function birthYearsMeet(card, other, slack = 0) {
|
|
359
|
+
if (card.from !== null && other.to !== null && card.from > other.to)
|
|
360
|
+
return false;
|
|
361
|
+
if (card.to !== null && other.from !== null && card.to + slack < other.from)
|
|
362
|
+
return false;
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
365
|
+
function underCapApplies(bandFrom, year) {
|
|
366
|
+
return bandFrom !== null && year - bandFrom <= UNDER_CAP_MAX_TOP;
|
|
367
|
+
}
|
|
368
|
+
function underCappedYears(b, year, n = UNDER_CAP.plain) {
|
|
369
|
+
if (n > UNDER_CAP.exceptional)
|
|
370
|
+
throw new Error(`under-cap: ${n} is over the exceptional cap of ${UNDER_CAP.exceptional}`);
|
|
371
|
+
if (!underCapApplies(b.from, year))
|
|
372
|
+
return b;
|
|
373
|
+
const cap = b.from + n;
|
|
374
|
+
if (b.to !== null && b.to <= cap)
|
|
375
|
+
return b;
|
|
376
|
+
return { from: b.from, to: cap };
|
|
377
|
+
}
|
|
378
|
+
function underCapRefuses(bandFrom, identityFrom, n) {
|
|
379
|
+
return identityFrom > bandFrom + n;
|
|
380
|
+
}
|
|
381
|
+
function youngFloor(year, oldest) {
|
|
382
|
+
const s = yearsSinceBirth(year, oldest);
|
|
383
|
+
if (s >= YOUNG_FLOOR.soft)
|
|
384
|
+
return "clear";
|
|
385
|
+
if (s < YOUNG_FLOOR.hard)
|
|
386
|
+
return "too-young";
|
|
387
|
+
return "same-school-only";
|
|
388
|
+
}
|
|
389
|
+
var tooYoungForMajor = (year, oldest) => yearsSinceBirth(year, oldest) < MIN_MAJOR_AGE;
|
|
390
|
+
var tooYoungForFeis = (year, oldest) => yearsSinceBirth(year, oldest) < MIN_FEIS_AGE;
|
|
391
|
+
export {
|
|
392
|
+
ADULT_FLOOR,
|
|
393
|
+
AVERAGE_AGE_DECIMALS,
|
|
394
|
+
Age,
|
|
395
|
+
AgeMisuseError,
|
|
396
|
+
DANCE_UP_YEARS,
|
|
397
|
+
MAJORS_FLOOR_YEARS,
|
|
398
|
+
MIN_FEIS_AGE,
|
|
399
|
+
MIN_MAJOR_AGE,
|
|
400
|
+
UNDER_CAP,
|
|
401
|
+
UNDER_CAP_MAX_TOP,
|
|
402
|
+
YOUNGEST_FLOOR,
|
|
403
|
+
YOUNG_FLOOR,
|
|
404
|
+
ageOnJanuary1,
|
|
405
|
+
ageRelation,
|
|
406
|
+
averageAge,
|
|
407
|
+
birthYearsDisjoint,
|
|
408
|
+
birthYearsMeet,
|
|
409
|
+
birthYearsOfInterval,
|
|
410
|
+
canOverlap,
|
|
411
|
+
isAveraged,
|
|
412
|
+
roundedMeanHundredths,
|
|
413
|
+
tooYoungForFeis,
|
|
414
|
+
tooYoungForMajor,
|
|
415
|
+
underCapApplies,
|
|
416
|
+
underCapRefuses,
|
|
417
|
+
underCappedYears,
|
|
418
|
+
yearsSinceBirth,
|
|
419
|
+
youngFloor
|
|
420
|
+
};
|
package/dist/ceili.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CEILI_NODES2,
|
|
3
|
+
allCeilis2,
|
|
4
|
+
ceiliById2,
|
|
5
|
+
ceili2,
|
|
6
|
+
ceiliRelation2
|
|
7
|
+
} from "./chunks/place-7gqecaxd.js";
|
|
8
|
+
import"./chunks/place-g6yw4cq6.js";
|
|
9
|
+
export {
|
|
10
|
+
CEILI_NODES2 as CEILI_NODES,
|
|
11
|
+
allCeilis2 as allCeilis,
|
|
12
|
+
ceili2 as ceili,
|
|
13
|
+
ceiliById2 as ceiliById,
|
|
14
|
+
ceiliRelation2 as ceiliRelation
|
|
15
|
+
};
|