calliope-ts 0.0.4 → 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/README.md +69 -3
- package/dist/calliope/bracketing.d.ts.map +1 -1
- package/dist/calliope/bracketing.js +59 -5
- package/dist/calliope/deps.d.ts +5 -1
- package/dist/calliope/deps.d.ts.map +1 -1
- package/dist/calliope/deps.js +53 -3
- package/dist/calliope/engine.d.ts.map +1 -1
- package/dist/calliope/engine.js +6 -1
- package/dist/calliope/relstress.d.ts.map +1 -1
- package/dist/calliope/relstress.js +411 -15
- package/dist/calliope/syntax.d.ts +33 -0
- package/dist/calliope/syntax.d.ts.map +1 -1
- package/dist/calliope/syntax.js +199 -0
- package/dist/data/en_common_words.json +1 -0
- package/dist/display.d.ts +15 -1
- package/dist/display.d.ts.map +1 -1
- package/dist/display.js +112 -10
- package/dist/fabbhalle.d.ts +54 -0
- package/dist/fabbhalle.d.ts.map +1 -0
- package/dist/fabbhalle.js +404 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +86 -33
- package/dist/phonological.d.ts.map +1 -1
- package/dist/phonological.js +7 -1
- package/dist/rewriteEn.d.ts +14 -0
- package/dist/rewriteEn.d.ts.map +1 -0
- package/dist/rewriteEn.js +392 -0
- package/dist/rhyme.d.ts +35 -3
- package/dist/rhyme.d.ts.map +1 -1
- package/dist/rhyme.js +176 -11
- package/dist/russian/accentuator.d.ts +31 -0
- package/dist/russian/accentuator.d.ts.map +1 -0
- package/dist/russian/accentuator.js +546 -0
- package/dist/russian/collocations.d.ts +7 -0
- package/dist/russian/collocations.d.ts.map +1 -0
- package/dist/russian/collocations.js +38 -0
- package/dist/russian/compounds.d.ts +6 -0
- package/dist/russian/compounds.d.ts.map +1 -0
- package/dist/russian/compounds.js +81 -0
- package/dist/russian/download.d.ts +23 -0
- package/dist/russian/download.d.ts.map +1 -0
- package/dist/russian/download.js +117 -0
- package/dist/russian/engine.d.ts +6 -0
- package/dist/russian/engine.d.ts.map +1 -0
- package/dist/russian/engine.js +1424 -0
- package/dist/russian/parser.d.ts +8 -0
- package/dist/russian/parser.d.ts.map +1 -0
- package/dist/russian/parser.js +92 -0
- package/dist/russian/paths.d.ts +55 -0
- package/dist/russian/paths.d.ts.map +1 -0
- package/dist/russian/paths.js +114 -0
- package/dist/russian/rewrite.d.ts +6 -0
- package/dist/russian/rewrite.d.ts.map +1 -0
- package/dist/russian/rewrite.js +400 -0
- package/dist/russian/rhyme.d.ts +18 -0
- package/dist/russian/rhyme.d.ts.map +1 -0
- package/dist/russian/rhyme.js +230 -0
- package/dist/russian/syllabifier.d.ts +14 -0
- package/dist/russian/syllabifier.d.ts.map +1 -0
- package/dist/russian/syllabifier.js +475 -0
- package/dist/russian/types.d.ts +94 -0
- package/dist/russian/types.d.ts.map +1 -0
- package/dist/russian/types.js +27 -0
- package/dist/scandroidNative/constants.d.ts +16 -0
- package/dist/scandroidNative/constants.d.ts.map +1 -0
- package/dist/scandroidNative/constants.js +60 -0
- package/dist/scandroidNative/deduceParameters.d.ts +16 -0
- package/dist/scandroidNative/deduceParameters.d.ts.map +1 -0
- package/dist/scandroidNative/deduceParameters.js +69 -0
- package/dist/scandroidNative/dictionary-data.d.ts +2 -0
- package/dist/scandroidNative/dictionary-data.d.ts.map +1 -0
- package/dist/scandroidNative/dictionary-data.js +744 -0
- package/dist/scandroidNative/dictionary.d.ts +12 -0
- package/dist/scandroidNative/dictionary.d.ts.map +1 -0
- package/dist/scandroidNative/dictionary.js +50 -0
- package/dist/scandroidNative/engine.d.ts +41 -0
- package/dist/scandroidNative/engine.d.ts.map +1 -0
- package/dist/scandroidNative/engine.js +119 -0
- package/dist/scandroidNative/machine.d.ts +93 -0
- package/dist/scandroidNative/machine.d.ts.map +1 -0
- package/dist/scandroidNative/machine.js +795 -0
- package/dist/scandroidNative/positioner.d.ts +32 -0
- package/dist/scandroidNative/positioner.d.ts.map +1 -0
- package/dist/scandroidNative/positioner.js +208 -0
- package/dist/scandroidNative/syllabizer.d.ts +3 -0
- package/dist/scandroidNative/syllabizer.d.ts.map +1 -0
- package/dist/scandroidNative/syllabizer.js +215 -0
- package/dist/scandroidNative/utilities.d.ts +37 -0
- package/dist/scandroidNative/utilities.d.ts.map +1 -0
- package/dist/scandroidNative/utilities.js +86 -0
- package/dist/scansion.d.ts.map +1 -1
- package/dist/scansion.js +118 -14
- package/dist/stress.d.ts +1 -0
- package/dist/stress.d.ts.map +1 -1
- package/dist/stress.js +502 -42
- package/dist/types.d.ts +3 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +27 -6
- package/tools/fetch-russian-data.mjs +30 -0
- package/webapp/public/app.js +2418 -0
- package/webapp/public/assets/GithubLogo.png +0 -0
- package/webapp/public/assets/NPM2.png +0 -0
- package/webapp/public/assets/fonts/MurenaBold-webfont.woff2 +0 -0
- package/webapp/public/assets/fonts/MurenaItalic-webfont.woff2 +0 -0
- package/webapp/public/assets/fonts/MurenaRegular-webfont.woff2 +0 -0
- package/webapp/public/assets/fonts/Myra_4F_Caps_Bold-webfont.woff2 +0 -0
- package/webapp/public/assets/fonts/SinkinSans-700Bold.otf +0 -0
- package/webapp/public/assets/fonts/butterflies.ttf +0 -0
- package/webapp/public/assets/fonts/midcase_blackline-webfont.woff2 +0 -0
- package/webapp/public/assets/fonts/neueral-bold-webfont.woff2 +0 -0
- package/webapp/public/assets/fonts/neueral-regular-webfont.woff2 +0 -0
- package/webapp/public/assets/fonts/popelka-webfont.woff2 +0 -0
- package/webapp/public/assets/fonts/vremenagrotesk-webfont.woff +0 -0
- package/webapp/public/assets/fonts/wicky_javick_bold-webfont.woff2 +0 -0
- package/webapp/public/assets/logos/Logo_Var_10_Celtic_Knot_SVG.svg +116 -0
- package/webapp/public/assets/logos/Logo_Var_11_Graph_SVG.svg +313 -0
- package/webapp/public/assets/logos/Logo_Var_12_Limacon_Knot_SVG.svg +3942 -0
- package/webapp/public/assets/logos/Logo_Var_13_Crescents_SVG.svg +1030 -0
- package/webapp/public/assets/logos/Logo_Var_1_Spiral_SVG.svg +1491 -0
- package/webapp/public/assets/logos/Logo_Var_2_Tunnel_SVG.svg +12 -0
- package/webapp/public/assets/logos/Logo_Var_3_Triskelion_SVG.svg +10 -0
- package/webapp/public/assets/logos/Logo_Var_4_Quinquetra_PNG.png +0 -0
- package/webapp/public/assets/logos/Logo_Var_5_Penrose_Pentagram_SVG.svg +37 -0
- package/webapp/public/assets/logos/Logo_Var_6_Triskelion2_SVG.svg +10 -0
- package/webapp/public/assets/logos/Logo_Var_7_SixfoldSpirals_SVG.svg +20 -0
- package/webapp/public/assets/logos/Logo_Var_8_Radial_Labyrinth_SVG.svg +163 -0
- package/webapp/public/assets/logos/Logo_Var_9_Radial_Labyrinth_2_SVG.svg +106 -0
- package/webapp/public/index.html +316 -0
- package/webapp/public/styles.css +1114 -0
- package/webapp/server.mjs +680 -0
- package/src/caesura.ts +0 -201
- package/src/calliope/boundaries.ts +0 -190
- package/src/calliope/bracketing.ts +0 -390
- package/src/calliope/deps.ts +0 -160
- package/src/calliope/engine.ts +0 -77
- package/src/calliope/feats.ts +0 -46
- package/src/calliope/names.ts +0 -44
- package/src/calliope/postag.ts +0 -253
- package/src/calliope/prosodic.ts +0 -262
- package/src/calliope/relstress.ts +0 -645
- package/src/calliope/stressrules.ts +0 -147
- package/src/calliope/syntax.ts +0 -218
- package/src/calliope/udpos.ts +0 -152
- package/src/calliope_src_contents.md +0 -19049
- package/src/clio/caesura.ts +0 -145
- package/src/clio/depfix.ts +0 -88
- package/src/clio/display.ts +0 -1042
- package/src/clio/engine.ts +0 -38
- package/src/clio/parser.ts +0 -845
- package/src/clio/phonological.ts +0 -849
- package/src/clio/phrasestress.ts +0 -108
- package/src/clio/pipeline.ts +0 -154
- package/src/clio/rhyme.ts +0 -740
- package/src/clio/scandroid.ts +0 -434
- package/src/clio/scansion.ts +0 -1130
- package/src/clio/semantics.ts +0 -134
- package/src/clio/stress.ts +0 -1731
- package/src/clio/tagfix.ts +0 -104
- package/src/depfix.ts +0 -88
- package/src/display.ts +0 -1138
- package/src/engine.ts +0 -22
- package/src/index.ts +0 -690
- package/src/parser.ts +0 -501
- package/src/phonological.ts +0 -917
- package/src/phrasestress.ts +0 -108
- package/src/rhyme.ts +0 -748
- package/src/scandroid.ts +0 -434
- package/src/scansion.ts +0 -1174
- package/src/semantics.ts +0 -134
- package/src/stress.ts +0 -2111
- package/src/tagfix.ts +0 -104
- package/src/types.ts +0 -265
- package/tests/DataForHayesLinesOnly.txt +0 -364
- package/tests/DataForHayesStressSymbolsRevised.txt +0 -728
- package/tests/basic.test.ts +0 -1102
- package/tests/bench-hayes.mjs +0 -72
- package/tests/epg64.meter.train.txt +0 -8139
- package/tests/litlab-sample-2016.txt +0 -1738
- package/tests/prosodic.meter.train.txt +0 -13192
- package/tests/wagner-stress.test.ts +0 -188
- package/tsconfig.json +0 -29
- package/vitest.config.d.ts +0 -2
- package/vitest.config.js +0 -14
|
@@ -0,0 +1,2418 @@
|
|
|
1
|
+
/* Calliope — front-end. Vanilla JS; talks to POST /api/analyze. */
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/* ═══════════════════ Reference data (plain-language glossaries) ═══════════════════ */
|
|
5
|
+
|
|
6
|
+
const TIERS = {
|
|
7
|
+
x: { name: 'zero-provision', color: 'var(--tier-x)', hint: 'maximally reduced function words: the, a, of, and, to' },
|
|
8
|
+
w: { name: 'weak', color: 'var(--tier-w)', hint: 'unstressed syllables of content words; unreduced function words' },
|
|
9
|
+
n: { name: 'low', color: 'var(--tier-n)', hint: 'lightly stressed syllables; pronouns and modals said with citation stress' },
|
|
10
|
+
m: { name: 'moderate', color: 'var(--tier-m)', hint: 'secondary stresses; stresses demoted by a stronger neighbour' },
|
|
11
|
+
s: { name: 'strong', color: 'var(--tier-s)', hint: 'primary stresses, phrase peaks, line-final nuclei' },
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const LEX_NAMES = { 0: 'unstressed', 1: 'secondary stress', 2: 'primary stress', 3: 'boosted primary' };
|
|
15
|
+
|
|
16
|
+
/* meter family hues — one ink-strength set for parchment, one bright set for the terminal */
|
|
17
|
+
const METER_HUE_INK = {
|
|
18
|
+
iambic: '#2f5e9e', trochaic: '#a06818', dactylic: '#1f6b47', amphibrachic: '#a04a78',
|
|
19
|
+
anapestic: '#b3402e', bacchic: '#5d3f8e', spondaic: '#5a544a', pyrrhic: '#5a544a',
|
|
20
|
+
'free verse': '#6b6355', free: '#6b6355',
|
|
21
|
+
};
|
|
22
|
+
const METER_HUE_TERM = {
|
|
23
|
+
iambic: '#57a7ff', trochaic: '#f0a848', dactylic: '#46d17e', amphibrachic: '#ff85c0',
|
|
24
|
+
anapestic: '#ff6b5e', bacchic: '#b39df1', spondaic: '#9a9a9a', pyrrhic: '#9a9a9a',
|
|
25
|
+
'free verse': '#9a9a9a', free: '#9a9a9a',
|
|
26
|
+
};
|
|
27
|
+
/* Russian meter hues — mapped by RPST internal names */
|
|
28
|
+
const RU_METER_HUE = {
|
|
29
|
+
iambos: '#57a7ff', choreios: '#f0a848', daktylos: '#46d17e',
|
|
30
|
+
amphibrachys: '#ff85c0', anapaistos: '#ff6b5e', dolnik2: '#b39df1',
|
|
31
|
+
dolnik3: '#b39df1', taktovik2: '#c9a227', taktovik3: '#c9a227',
|
|
32
|
+
free: '#9a9a9a',
|
|
33
|
+
};
|
|
34
|
+
const RU_METER_DEF = {
|
|
35
|
+
iambos: { ru: 'ямб', en: 'iambic', hint: 'rising duple — offbeat then beat' },
|
|
36
|
+
choreios: { ru: 'хорей', en: 'trochaic', hint: 'falling duple — beat then offbeat' },
|
|
37
|
+
daktylos: { ru: 'дактиль', en: 'dactylic', hint: 'falling triple — beat then two offbeats' },
|
|
38
|
+
amphibrachys: { ru: 'амфибрахий', en: 'amphibrachic', hint: 'beat cradled by offbeats' },
|
|
39
|
+
anapaistos: { ru: 'анапест', en: 'anapestic', hint: 'rising triple — two offbeats then beat' },
|
|
40
|
+
dolnik2: { ru: 'дольник 2', en: 'dolnik', hint: 'variable 0–1 offbeats between beats' },
|
|
41
|
+
dolnik3: { ru: 'дольник 3', en: 'dolnik', hint: 'variable 0–2 offbeats between beats' },
|
|
42
|
+
taktovik2: { ru: 'тактовик 2', en: 'taktovik', hint: 'variable beats with wider gaps' },
|
|
43
|
+
taktovik3: { ru: 'тактовик 3', en: 'taktovik', hint: 'variable beats with wider gaps' },
|
|
44
|
+
free: { ru: 'вольный', en: 'free verse', hint: 'no regular meter detected' },
|
|
45
|
+
};
|
|
46
|
+
let METER_HUE = METER_HUE_TERM;
|
|
47
|
+
const METER_DEF = {
|
|
48
|
+
iambic: { feet: '˘ ¯', eg: 'be-LOW', hint: 'rising duple — an offbeat then a beat' },
|
|
49
|
+
trochaic: { feet: '¯ ˘', eg: 'TY-ger', hint: 'falling duple — a beat then an offbeat' },
|
|
50
|
+
anapestic: { feet: '˘ ˘ ¯', eg: 'in-ter-VENE', hint: 'rising triple — two offbeats then a beat' },
|
|
51
|
+
dactylic: { feet: '¯ ˘ ˘', eg: 'MUR-mur-ing', hint: 'falling triple — a beat then two offbeats' },
|
|
52
|
+
amphibrachic: { feet: '˘ ¯ ˘', eg: 'a-MA-zing', hint: 'a beat cradled by offbeats' },
|
|
53
|
+
bacchic: { feet: '˘ ¯ ¯', eg: 'a-BOVE-BOARD',hint: 'one offbeat then two beats' },
|
|
54
|
+
spondaic: { feet: '¯ ¯', eg: 'HEART-BREAK', hint: 'two beats side by side' },
|
|
55
|
+
pyrrhic: { feet: '˘ ˘', eg: 'of the', hint: 'two offbeats — a resting foot' },
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const RHYME_TYPES = {
|
|
59
|
+
perfect: 'the classic full rhyme — stressed vowel and everything after it match (grace / face)',
|
|
60
|
+
rich: 'a perfect rhyme that also shares the consonant before the vowel (stationary / stationery)',
|
|
61
|
+
identical: 'the very same word (or homophone) repeated at line-end',
|
|
62
|
+
family: 'final consonants are siblings — same manner, different voicing (bat / bad)',
|
|
63
|
+
assonant: 'the vowels agree but the consonants after them differ (lake / fate)',
|
|
64
|
+
consonant: 'the consonants agree but the vowels differ (blank / think)',
|
|
65
|
+
augmented: 'the rhyme extended by one final consonant (bray / brave, grow / sown)',
|
|
66
|
+
diminished:'the mirror of augmented — the final consonant dropped (stained / rain)',
|
|
67
|
+
grammatical:'stressed vowel matches at the roots; the tails are inflections (pun / running, fun / funny)',
|
|
68
|
+
mosaic: 'one word’s rhyme spans SEVERAL words on the other line (tenderly / slender — see?)',
|
|
69
|
+
wrenched: 'a stressed syllable rhymed against an unstressed one (wing / dancing)',
|
|
70
|
+
eye: 'looks like a rhyme on the page but not in the mouth (love / move)',
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const POS_GLOSS = {
|
|
74
|
+
NN: 'noun', NNS: 'plural noun', NNP: 'proper noun', NNPS: 'plural proper noun',
|
|
75
|
+
VB: 'verb (base form)', VBD: 'verb (past tense)', VBG: 'verb (-ing form)',
|
|
76
|
+
VBN: 'verb (past participle)', VBP: 'verb (present)', VBZ: 'verb (he/she/it form)',
|
|
77
|
+
MD: 'modal verb', JJ: 'adjective', JJR: 'comparative adjective', JJS: 'superlative adjective',
|
|
78
|
+
RB: 'adverb', RBR: 'comparative adverb', RBS: 'superlative adverb',
|
|
79
|
+
DT: 'determiner', PDT: 'predeterminer', CD: 'number',
|
|
80
|
+
PRP: 'personal pronoun', 'PRP$': 'possessive pronoun',
|
|
81
|
+
WDT: 'wh-determiner', WP: 'wh-pronoun', 'WP$': 'possessive wh-pronoun', WRB: 'wh-adverb',
|
|
82
|
+
IN: 'preposition / subordinator', TO: 'infinitival “to”', CC: 'coordinating conjunction',
|
|
83
|
+
RP: 'particle', EX: 'existential “there”', POS: 'possessive ending', UH: 'interjection', FW: 'foreign word',
|
|
84
|
+
};
|
|
85
|
+
const POS_EG = {
|
|
86
|
+
NN: 'table, water', NNS: 'tables, waters', NNP: 'London, Pound', NNPS: 'Americans',
|
|
87
|
+
VB: 'throw, run', VBD: 'threw, ran', VBG: 'throwing', VBN: 'thrown', VBP: '(I) throw', VBZ: 'throws',
|
|
88
|
+
MD: 'can, must', JJ: 'green, large', JJR: 'greener', JJS: 'greenest',
|
|
89
|
+
RB: 'quickly, very', RBR: 'faster', RBS: 'fastest',
|
|
90
|
+
DT: 'the, a, an', PDT: 'all (the books)', CD: 'one, two',
|
|
91
|
+
PRP: 'I, you, they', 'PRP$': 'my, their',
|
|
92
|
+
WDT: 'which, that', WP: 'who, what', 'WP$': 'whose', WRB: 'when, why',
|
|
93
|
+
IN: 'in, of, although', TO: 'to (go)', CC: 'and, but, or',
|
|
94
|
+
RP: 'up (give up)', EX: 'there (is)', POS: '’s', UH: 'oh, wow', FW: 'je ne sais quoi',
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const DEP_GLOSS = {
|
|
98
|
+
nsubj: 'subject of', nsubjpass: 'passive subject of', csubj: 'clausal subject of',
|
|
99
|
+
dobj: 'direct object of', obj: 'object of', iobj: 'indirect object of',
|
|
100
|
+
pobj: 'object of preposition', obl: 'oblique complement of',
|
|
101
|
+
ccomp: 'clausal complement of', xcomp: 'open complement of',
|
|
102
|
+
advcl: 'adverbial clause under', acl: 'clause modifying', 'acl:relcl': 'relative clause on',
|
|
103
|
+
amod: 'adjective modifying', advmod: 'adverb modifying', nummod: 'number on',
|
|
104
|
+
nmod: 'noun modifying', 'nmod:poss': 'possessor of', poss: 'possessor of',
|
|
105
|
+
appos: 'in apposition to', det: 'determiner of', 'det:predet': 'predeterminer of',
|
|
106
|
+
prep: 'preposition attached to', case: 'case marker of',
|
|
107
|
+
aux: 'auxiliary of', 'aux:pass': 'passive auxiliary of', auxpass: 'passive auxiliary of',
|
|
108
|
+
cop: 'linking verb of', cc: 'conjunction joining', conj: 'conjoined with',
|
|
109
|
+
mark: 'clause marker for', prt: 'particle of', 'compound:prt': 'particle of',
|
|
110
|
+
compound: 'compounded with', flat: 'part of the name', fixed: 'fixed phrase with',
|
|
111
|
+
expl: 'expletive for', discourse: 'discourse marker in', intj: 'interjection in',
|
|
112
|
+
vocative: 'vocative in', parataxis: 'set beside', dep: 'loosely attached to',
|
|
113
|
+
root: 'the root of the clause', punct: 'punctuation',
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const FEAT_KEY_GLOSS = {
|
|
117
|
+
Tense: 'tense', Number: 'number', Person: 'person', Mood: 'mood', VerbForm: 'verb form',
|
|
118
|
+
Degree: 'degree', PronType: 'pronoun type', Definite: 'definiteness', Voice: 'voice',
|
|
119
|
+
Case: 'case', NumType: 'number type', Poss: 'possessive', Gender: 'gender',
|
|
120
|
+
Foreign: 'foreign', Abbr: 'abbreviation', Reflex: 'reflexive', Polarity: 'polarity',
|
|
121
|
+
ExtPos: 'phrase role', Style: 'register', Typo: 'typo',
|
|
122
|
+
};
|
|
123
|
+
const FEAT_VAL_GLOSS = {
|
|
124
|
+
Past: 'past', Pres: 'present', Fut: 'future', Sing: 'singular', Plur: 'plural',
|
|
125
|
+
Ind: 'indicative', Imp: 'imperative', Sub: 'subjunctive', Cnd: 'conditional',
|
|
126
|
+
Fin: 'finite', Inf: 'infinitive', Part: 'participle', Ger: 'gerund',
|
|
127
|
+
Pos: 'positive', Cmp: 'comparative', Sup: 'superlative',
|
|
128
|
+
Prs: 'personal', Art: 'article', Dem: 'demonstrative', Int: 'interrogative',
|
|
129
|
+
Rel: 'relative', Neg: 'negative', Tot: 'total', Ind_: 'indefinite',
|
|
130
|
+
Def: 'definite', Nom: 'nominative', Acc: 'accusative', Gen: 'genitive',
|
|
131
|
+
Card: 'cardinal', Ord: 'ordinal', Mult: 'multiplicative',
|
|
132
|
+
Act: 'active', Pass: 'passive', Yes: 'yes', Masc: 'masculine', Fem: 'feminine', Neut: 'neuter',
|
|
133
|
+
'1': '1st', '2': '2nd', '3': '3rd', Arch: 'archaic', Expr: 'expressive',
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const RHYME_PAL_INK = ['#1f6b47', '#5d3f8e', '#b3402e', '#a06818', '#2f5e9e', '#a04a78', '#4f7a1f', '#0f7a8a', '#8a3f9e', '#8c1015'];
|
|
137
|
+
const RHYME_PAL_TERM = ['#46d17e', '#b39df1', '#ff6b5e', '#f0a848', '#57a7ff', '#ff85c0', '#a3d465', '#3ecfe0', '#d78cff', '#ff9d9d'];
|
|
138
|
+
let RHYME_LETTER_PALETTE = RHYME_PAL_TERM;
|
|
139
|
+
|
|
140
|
+
const GRAD_STOPS = [
|
|
141
|
+
[0.00, [0x6a, 0x8c, 0xc7]], [0.30, [0x5f, 0xc7, 0xc0]], [0.55, [0xd9, 0xc2, 0x4d]],
|
|
142
|
+
[0.78, [0xe0, 0x91, 0x3f]], [1.00, [0xe0, 0x56, 0x4b]],
|
|
143
|
+
];
|
|
144
|
+
function grad(t) {
|
|
145
|
+
const x = Math.max(0, Math.min(1, t));
|
|
146
|
+
for (let i = 1; i < GRAD_STOPS.length; i++) {
|
|
147
|
+
const [t1, c1] = GRAD_STOPS[i - 1], [t2, c2] = GRAD_STOPS[i];
|
|
148
|
+
if (x <= t2) {
|
|
149
|
+
const f = t2 === t1 ? 0 : (x - t1) / (t2 - t1);
|
|
150
|
+
return '#' + c1.map((v, k) => Math.round(v + (c2[k] - v) * f).toString(16).padStart(2, '0')).join('');
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return '#e0564b';
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const SAMPLES = [
|
|
157
|
+
{ name: 'Shakespeare · Sonnet 130', text:
|
|
158
|
+
`My mistress' eyes are nothing like the sun;
|
|
159
|
+
Coral is far more red than her lips' red;
|
|
160
|
+
If snow be white, why then her breasts are dun;
|
|
161
|
+
If hairs be wires, black wires grow on her head.
|
|
162
|
+
I have seen roses damasked, red and white,
|
|
163
|
+
But no such roses see I in her cheeks;
|
|
164
|
+
And in some perfumes is there more delight
|
|
165
|
+
Than in the breath that from my mistress reeks.
|
|
166
|
+
I love to hear her speak, yet well I know
|
|
167
|
+
That music hath a far more pleasing sound;
|
|
168
|
+
I grant I never saw a goddess go;
|
|
169
|
+
My mistress, when she walks, treads on the ground.
|
|
170
|
+
And yet, by heaven, I think my love as rare
|
|
171
|
+
As any she belied with false compare.` },
|
|
172
|
+
{ name: 'Coleridge · Ancient Mariner (opening)', text:
|
|
173
|
+
`It is an ancient Mariner,
|
|
174
|
+
And he stoppeth one of three.
|
|
175
|
+
"By thy long grey beard and glittering eye,
|
|
176
|
+
Now wherefore stopp'st thou me?` },
|
|
177
|
+
{ name: 'Blake · The Tyger (two stanzas)', text:
|
|
178
|
+
`Tyger Tyger, burning bright,
|
|
179
|
+
In the forests of the night;
|
|
180
|
+
What immortal hand or eye,
|
|
181
|
+
Could frame thy fearful symmetry?
|
|
182
|
+
|
|
183
|
+
In what distant deeps or skies,
|
|
184
|
+
Burnt the fire of thine eyes?
|
|
185
|
+
On what wings dare he aspire?
|
|
186
|
+
What the hand, dare seize the fire?` },
|
|
187
|
+
{ name: 'Wyatt · They flee from me (stanza)', text:
|
|
188
|
+
`They flee from me that sometime did me seek
|
|
189
|
+
With naked foot, stalking in my chamber.
|
|
190
|
+
I have seen them gentle, tame, and meek,
|
|
191
|
+
That now are wild and do not remember
|
|
192
|
+
That sometime they put themself in danger
|
|
193
|
+
To take bread at my hand; and now they range,
|
|
194
|
+
Busily seeking with a continual change.` },
|
|
195
|
+
{ name: 'Dickinson · Because I could not stop', text:
|
|
196
|
+
`Because I could not stop for Death –
|
|
197
|
+
He kindly stopped for me –
|
|
198
|
+
The Carriage held but just Ourselves –
|
|
199
|
+
And Immortality.
|
|
200
|
+
|
|
201
|
+
We slowly drove – He knew no haste
|
|
202
|
+
And I had put away
|
|
203
|
+
My labor and my leisure too,
|
|
204
|
+
For His Civility –` },
|
|
205
|
+
{ name: 'Lear · a limerick', text:
|
|
206
|
+
`There was an Old Man with a beard,
|
|
207
|
+
Who said, "It is just as I feared!
|
|
208
|
+
Two Owls and a Hen,
|
|
209
|
+
Four Larks and a Wren,
|
|
210
|
+
Have all built their nests in my beard!"` },
|
|
211
|
+
{ name: 'Frost · Mending Wall (opening)', text:
|
|
212
|
+
`Something there is that doesn't love a wall,
|
|
213
|
+
That sends the frozen-ground-swell under it,
|
|
214
|
+
And spills the upper boulders in the sun;
|
|
215
|
+
And makes gaps even two can pass abreast.` },
|
|
216
|
+
{ name: 'Пушкин · Я помню чудное мгновенье', lang: 'ru', text:
|
|
217
|
+
`Я помню чудное мгновенье:
|
|
218
|
+
Передо мной явилась ты,
|
|
219
|
+
Как мимолетное виденье,
|
|
220
|
+
Как гений чистой красоты.
|
|
221
|
+
|
|
222
|
+
В томлениях грусти безнадежной,
|
|
223
|
+
В тревогах шумной суеты,
|
|
224
|
+
Звучал мне долго голос нежный
|
|
225
|
+
И снились милые черты.` },
|
|
226
|
+
{ name: 'Лермонтов · Парус', lang: 'ru', text:
|
|
227
|
+
`Белеет парус одинокой
|
|
228
|
+
В тумане моря голубом!
|
|
229
|
+
Что ищет он в стране далекой?
|
|
230
|
+
Что кинул он в краю родном?
|
|
231
|
+
|
|
232
|
+
Играют волны, ветер свищет,
|
|
233
|
+
И мачта гнется и скрипит...
|
|
234
|
+
Увы! он счастия не ищет
|
|
235
|
+
И не от счастия бежит!
|
|
236
|
+
|
|
237
|
+
Под ним струя светлей лазури,
|
|
238
|
+
Над ним луч солнца золотой...
|
|
239
|
+
А он, мятежный, просит бури,
|
|
240
|
+
Как будто в бурях есть покой!` },
|
|
241
|
+
{ name: 'Ахмадулина · Вулканы', lang: 'ru', text:
|
|
242
|
+
`Молчат потухшие вулканы,
|
|
243
|
+
на дно их падает зола.
|
|
244
|
+
Там отдыхают великаны
|
|
245
|
+
после содеянного зла.` },
|
|
246
|
+
];
|
|
247
|
+
|
|
248
|
+
const VEIL_LINES = [
|
|
249
|
+
'Sounding the syllables…', 'Weighing every beat…', 'Listening for the caesurae…',
|
|
250
|
+
'Grouping words the way speech groups them…', 'Auditioning seven meters…',
|
|
251
|
+
'Consulting the pronouncing dictionary…', 'Tracing the dependency tree…',
|
|
252
|
+
];
|
|
253
|
+
|
|
254
|
+
/* ═══════════════════ State & elements ═══════════════════ */
|
|
255
|
+
|
|
256
|
+
const $ = (sel, root = document) => root.querySelector(sel);
|
|
257
|
+
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
|
|
258
|
+
|
|
259
|
+
const state = {
|
|
260
|
+
data: null,
|
|
261
|
+
engine: 'calliope',
|
|
262
|
+
lang: 'en',
|
|
263
|
+
tint: 'rel',
|
|
264
|
+
showFeet: false,
|
|
265
|
+
pinned: null, // { s, l, w }
|
|
266
|
+
theme: localStorage.getItem('calliope-theme') || 'terminal',
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
function applyTheme() {
|
|
270
|
+
const term = state.theme === 'terminal';
|
|
271
|
+
document.body.classList.toggle('theme-terminal', term);
|
|
272
|
+
METER_HUE = term ? METER_HUE_TERM : METER_HUE_INK;
|
|
273
|
+
RHYME_LETTER_PALETTE = term ? RHYME_PAL_TERM : RHYME_PAL_INK;
|
|
274
|
+
}
|
|
275
|
+
applyTheme();
|
|
276
|
+
|
|
277
|
+
const els = {
|
|
278
|
+
input: $('#poem-input'),
|
|
279
|
+
analyze: $('#btn-analyze'),
|
|
280
|
+
scriptorium: $('#scriptorium'),
|
|
281
|
+
editStrip: $('#edit-strip'),
|
|
282
|
+
reopen: $('#btn-reopen'),
|
|
283
|
+
resultsZone: $('#results-zone'),
|
|
284
|
+
codex: $('#codex'),
|
|
285
|
+
synopsis: $('#synopsis-card'),
|
|
286
|
+
phono: $('#phonopoetics-card'),
|
|
287
|
+
inspectorBody: $('#inspector-body'),
|
|
288
|
+
inspectorHint: $('#inspector-hint'),
|
|
289
|
+
emptyHint: $('#empty-hint'),
|
|
290
|
+
tooltip: $('#tooltip'),
|
|
291
|
+
veil: $('#veil'),
|
|
292
|
+
veilText: $('#veil-text'),
|
|
293
|
+
legend: $('#legend'),
|
|
294
|
+
legendBody: $('#legend-body'),
|
|
295
|
+
toast: $('#toast'),
|
|
296
|
+
sampleSelect: $('#sample-select'),
|
|
297
|
+
tintSelect: $('#tint-select'),
|
|
298
|
+
feetToggle: $('#feet-toggle'),
|
|
299
|
+
engineNote: $('#engine-note'),
|
|
300
|
+
langSelect: $('#lang-select'),
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
/* ═══════════════════ Small helpers ═══════════════════ */
|
|
304
|
+
|
|
305
|
+
const esc = (s) => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
306
|
+
|
|
307
|
+
function el(tag, attrs = {}, ...children) {
|
|
308
|
+
const node = document.createElement(tag);
|
|
309
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
310
|
+
if (v == null) continue;
|
|
311
|
+
if (k === 'class') node.className = v;
|
|
312
|
+
else if (k === 'html') node.innerHTML = v;
|
|
313
|
+
else if (k === 'text') node.textContent = v;
|
|
314
|
+
else if (k.startsWith('on')) node.addEventListener(k.slice(2), v);
|
|
315
|
+
else if (k === 'style' && typeof v === 'object') Object.assign(node.style, v);
|
|
316
|
+
else node.setAttribute(k, v);
|
|
317
|
+
}
|
|
318
|
+
for (const c of children.flat()) {
|
|
319
|
+
if (c == null) continue;
|
|
320
|
+
node.append(c instanceof Node ? c : document.createTextNode(c));
|
|
321
|
+
}
|
|
322
|
+
return node;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const meterFamily = (name) => (name || '').split(' ')[0].toLowerCase();
|
|
326
|
+
const meterColor = (name) => METER_HUE[meterFamily(name)] ?? METER_HUE.free;
|
|
327
|
+
const letterColor = (letter) => {
|
|
328
|
+
const code = (letter || 'A').toUpperCase().charCodeAt(0) - 65;
|
|
329
|
+
return RHYME_LETTER_PALETTE[((code % RHYME_LETTER_PALETTE.length) + RHYME_LETTER_PALETTE.length) % RHYME_LETTER_PALETTE.length];
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
/** Colour any meter-family words inside a plain string (for synopsis rows). */
|
|
333
|
+
function tintMeterNames(str) {
|
|
334
|
+
return esc(str).replace(
|
|
335
|
+
/\b(iambic|trochaic|dactylic|amphibrachic|anapestic|bacchic|spondaic|pyrrhic|iamb|troch|dact|amph|anap|bacch|spon|pyrr)\b/gi,
|
|
336
|
+
(w) => {
|
|
337
|
+
const k = w.toLowerCase();
|
|
338
|
+
const fam = k.startsWith('iamb') ? 'iambic' : k.startsWith('troch') ? 'trochaic'
|
|
339
|
+
: k.startsWith('dact') ? 'dactylic' : k.startsWith('amph') ? 'amphibrachic'
|
|
340
|
+
: k.startsWith('anap') ? 'anapestic' : k.startsWith('bacch') ? 'bacchic'
|
|
341
|
+
: k.startsWith('spon') ? 'spondaic' : 'pyrrhic';
|
|
342
|
+
return `<span style="color:${METER_HUE[fam]}">${w}</span>`;
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function featPhrase(key, val) {
|
|
347
|
+
const k = FEAT_KEY_GLOSS[key] ?? key;
|
|
348
|
+
const v = FEAT_VAL_GLOSS[val] ?? val;
|
|
349
|
+
return { k, v };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function depPhrase(word) {
|
|
353
|
+
if (!word.dep) return null;
|
|
354
|
+
if (word.dep.isRoot) return { rel: 'root', text: 'the root of the clause — everything else hangs from it' };
|
|
355
|
+
const gloss = DEP_GLOSS[word.dep.rel] ?? `“${word.dep.rel}” of`;
|
|
356
|
+
return { rel: word.dep.rel, text: `${gloss} “${word.dep.govWord}”` };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function caesuraGlyph(c) {
|
|
360
|
+
const glyph = c.strength < 0.34 ? '·' : (c.kind === 'hard' ? '‖' : '¦');
|
|
361
|
+
const label = c.kind === 'hard' ? 'caesura — a real spoken pause here' : 'lighter, inferred phrase break';
|
|
362
|
+
return { glyph, color: grad(c.strength), label: `${label} (strength ${(c.strength * 100) | 0}%)` };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function toast(msg) {
|
|
366
|
+
els.toast.textContent = msg;
|
|
367
|
+
els.toast.hidden = false;
|
|
368
|
+
clearTimeout(toast._t);
|
|
369
|
+
toast._t = setTimeout(() => { els.toast.hidden = true; }, 4200);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/* ═══════════════════ Analyze flow ═══════════════════ */
|
|
373
|
+
|
|
374
|
+
let veilTimer = null;
|
|
375
|
+
function showVeil() {
|
|
376
|
+
let i = 0;
|
|
377
|
+
els.veilText.textContent = VEIL_LINES[0];
|
|
378
|
+
els.veil.hidden = false;
|
|
379
|
+
veilTimer = setInterval(() => { els.veilText.textContent = VEIL_LINES[++i % VEIL_LINES.length]; }, 1400);
|
|
380
|
+
}
|
|
381
|
+
function hideVeil() { clearInterval(veilTimer); els.veil.hidden = true; }
|
|
382
|
+
|
|
383
|
+
async function analyze() {
|
|
384
|
+
const text = els.input.value.trim();
|
|
385
|
+
if (!text) { toast('Give Calliope a line or two first.'); return; }
|
|
386
|
+
els.analyze.disabled = true;
|
|
387
|
+
showVeil();
|
|
388
|
+
try {
|
|
389
|
+
const isRu = state.lang === 'ru';
|
|
390
|
+
const endpoint = isRu ? '/api/russian' : '/api/analyze';
|
|
391
|
+
const payload = isRu ? { text } : { text, engine: state.engine };
|
|
392
|
+
const r = await fetch(endpoint, {
|
|
393
|
+
method: 'POST',
|
|
394
|
+
headers: { 'content-type': 'application/json' },
|
|
395
|
+
body: JSON.stringify(payload),
|
|
396
|
+
});
|
|
397
|
+
const data = await r.json();
|
|
398
|
+
if (!r.ok || data.error) throw new Error(data.error || `server said ${r.status}`);
|
|
399
|
+
state.data = data;
|
|
400
|
+
state.data._lang = isRu ? 'ru' : 'en';
|
|
401
|
+
state.pinned = null;
|
|
402
|
+
renderAll();
|
|
403
|
+
els.scriptorium.hidden = true;
|
|
404
|
+
els.editStrip.hidden = false;
|
|
405
|
+
els.resultsZone.hidden = false;
|
|
406
|
+
els.emptyHint.hidden = true;
|
|
407
|
+
if (isRu) {
|
|
408
|
+
els.engineNote.textContent = `Russian · ${data.elapsedMs ?? '—'} ms`;
|
|
409
|
+
} else {
|
|
410
|
+
els.engineNote.textContent = `${data.engine === 'clio' ? 'Clio (legacy parse)' : 'Calliope'} · ${data.elapsedMs} ms`;
|
|
411
|
+
}
|
|
412
|
+
} catch (err) {
|
|
413
|
+
console.error(err);
|
|
414
|
+
const netFail = err instanceof TypeError || /fetch/i.test(err.message);
|
|
415
|
+
toast(netFail
|
|
416
|
+
? 'The engine isn\'t answering — start it with `npm run web` and open http://localhost:4321.'
|
|
417
|
+
: 'The scansion faltered: ' + err.message);
|
|
418
|
+
} finally {
|
|
419
|
+
hideVeil();
|
|
420
|
+
els.analyze.disabled = false;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/* ═══════════════════ Rendering: the codex ═══════════════════ */
|
|
425
|
+
|
|
426
|
+
/* grammatical status: root / argument / modifier / functor / satellite */
|
|
427
|
+
const ARG_RELS = new Set(['nsubj', 'nsubjpass', 'csubj', 'obj', 'dobj', 'iobj']);
|
|
428
|
+
const MOD_RELS = new Set(['amod', 'advmod', 'nmod', 'nummod', 'acl', 'acl:relcl', 'advcl', 'appos', 'poss', 'nmod:poss', 'obl', 'pobj']);
|
|
429
|
+
function wordStatus(w) {
|
|
430
|
+
if (!w.dep) return { label: 'unattached', hint: 'no dependency edge parsed', color: 'var(--sepia)' };
|
|
431
|
+
if (w.dep.isRoot) return { label: 'root', hint: 'the head of the whole clause — everything else relies on it', color: 'var(--viridian)' };
|
|
432
|
+
const r = w.dep.rel;
|
|
433
|
+
if (ARG_RELS.has(r)) return { label: 'argument', hint: `a core argument (${r}) — who or what the predicate involves`, color: 'var(--blue)' };
|
|
434
|
+
if (MOD_RELS.has(r)) return { label: 'modifier', hint: `a modifier (${r}) — colours its companion without being required by it`, color: 'var(--rose)' };
|
|
435
|
+
if (!w.isContent) return { label: 'functor', hint: `grammatical machinery (${r}) — a function word in service of the phrase`, color: 'var(--amber)' };
|
|
436
|
+
return { label: r, hint: DEP_GLOSS[r] ?? r, color: 'var(--sepia)' };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/* poem-wide sound roles: end/caesural/head rhymes + alliteration, per word */
|
|
440
|
+
function buildPoemRoles() {
|
|
441
|
+
const roles = new Map(); // lowercase word → [{label, hint, color}]
|
|
442
|
+
const add = (word, role) => {
|
|
443
|
+
const k = String(word).toLowerCase();
|
|
444
|
+
if (!roles.has(k)) roles.set(k, []);
|
|
445
|
+
if (!roles.get(k).some(r => r.label === role.label)) roles.get(k).push(role);
|
|
446
|
+
};
|
|
447
|
+
const p = state.data?.phonopoetics;
|
|
448
|
+
if (p) {
|
|
449
|
+
const KIND_HINT = { end: 'an end rhyme — binds line-ends', caesural: 'a caesural rhyme — lands just before a mid-line pause', head: 'a head rhyme — rings at line-openings' };
|
|
450
|
+
for (const kind of ['end', 'caesural', 'head']) {
|
|
451
|
+
for (const r of p[kind] ?? []) {
|
|
452
|
+
const hintA = `${KIND_HINT[kind]}; ${r.type ?? ''} rhyme with “${r.toWord}” (${r.toLabel})`;
|
|
453
|
+
const hintB = `${KIND_HINT[kind]}; ${r.type ?? ''} rhyme with “${r.fromWord}” (${r.fromLabel})`;
|
|
454
|
+
add(r.fromWord, { label: `${kind} rhyme ${r.letter}`, hint: hintA, color: letterColor(r.letter) });
|
|
455
|
+
add(r.toWord, { label: `${kind} rhyme ${r.letter}`, hint: hintB, color: letterColor(r.letter) });
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
for (const a of p.alliteration ?? []) {
|
|
459
|
+
for (const word of a.words) {
|
|
460
|
+
add(word, { label: `alliterates (${a.label})`, hint: `alliterates with: ${a.words.join(', ')}`, color: 'var(--teal)' });
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
state.poemRoles = roles;
|
|
465
|
+
}
|
|
466
|
+
function rolesFor(w) {
|
|
467
|
+
const keys = [w.norm?.toLowerCase(), (w.text || '').toLowerCase().replace(/[^a-z'’-]/g, '')];
|
|
468
|
+
for (const k of keys) { if (k && state.poemRoles?.has(k)) return state.poemRoles.get(k); }
|
|
469
|
+
return [];
|
|
470
|
+
}
|
|
471
|
+
function dependentsOf(line, wordIdx) {
|
|
472
|
+
return (line.deps ?? [])
|
|
473
|
+
.filter(d => d.to === wordIdx && d.from !== wordIdx)
|
|
474
|
+
.map(d => ({ word: line.words[d.from]?.text ?? '?', rel: d.rel }));
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/* Zipf reading: nounsing lexicon freq is on the Zipf scale (~1–7) */
|
|
478
|
+
function zipfGloss(f) {
|
|
479
|
+
const z = Number(f);
|
|
480
|
+
if (!isFinite(z)) return null;
|
|
481
|
+
const band = z >= 5 ? 'everyday-common' : z >= 4 ? 'common' : z >= 3 ? 'moderately common' : z >= 2 ? 'uncommon' : 'rare';
|
|
482
|
+
return `Zipf ${z.toFixed(2)} — ${band}`;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function renderAll() {
|
|
486
|
+
if (state.data?._lang === 'ru') { renderRussianAll(); return; }
|
|
487
|
+
buildPoemRoles();
|
|
488
|
+
renderCodex();
|
|
489
|
+
renderSynopsis();
|
|
490
|
+
renderPhonopoetics();
|
|
491
|
+
renderInspector();
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function tintClass() {
|
|
495
|
+
return { rel: 'tint-rel', lex: 'tint-lex', class: 'tint-class', off: 'tint-off' }[state.tint];
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function renderCodex() {
|
|
499
|
+
const data = state.data;
|
|
500
|
+
els.codex.innerHTML = '';
|
|
501
|
+
els.codex.className = 'codex panel ' + tintClass();
|
|
502
|
+
const multi = data.stanzas.length > 1;
|
|
503
|
+
let poemLineNo = 0;
|
|
504
|
+
|
|
505
|
+
data.stanzas.forEach((st, sIdx) => {
|
|
506
|
+
const stanza = el('div', { class: 'stanza' });
|
|
507
|
+
if (multi || st.formNote) {
|
|
508
|
+
stanza.append(el('div', { class: 'stanza-head' },
|
|
509
|
+
multi ? el('span', { class: 'stanza-no', text: `Stanza ${sIdx + 1}` }) : null,
|
|
510
|
+
st.formNote ? el('span', { class: 'form-note', text: st.formNote, title: 'the stanza’s poetic form' }) : null,
|
|
511
|
+
));
|
|
512
|
+
}
|
|
513
|
+
st.lines.forEach((line, lIdx) => {
|
|
514
|
+
poemLineNo++;
|
|
515
|
+
stanza.append(renderLine(line, sIdx, lIdx, poemLineNo));
|
|
516
|
+
});
|
|
517
|
+
els.codex.append(stanza);
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function renderLine(line, sIdx, lIdx, no) {
|
|
522
|
+
const row = el('div', { class: 'vline', 'data-s': sIdx, 'data-l': lIdx });
|
|
523
|
+
const main = el('div', { class: 'vline-main' });
|
|
524
|
+
main.append(el('span', { class: 'vline-no', text: String(no) }));
|
|
525
|
+
|
|
526
|
+
if (!line.parsed) {
|
|
527
|
+
main.append(el('span', { class: 'vline-text' }, el('span', { class: 'gap', text: line.raw })));
|
|
528
|
+
main.append(el('span', { class: 'vline-noparse', text: '(no parse)' }));
|
|
529
|
+
row.append(main);
|
|
530
|
+
return row;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
main.append(renderLineText(line, sIdx, lIdx));
|
|
534
|
+
main.append(el('span', { class: 'vline-lead', 'aria-hidden': 'true' }));
|
|
535
|
+
main.append(renderChips(line, sIdx, lIdx));
|
|
536
|
+
row.append(main);
|
|
537
|
+
return row;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function renderLineText(line, sIdx, lIdx) {
|
|
541
|
+
const wrap = el('span', { class: 'vline-text' });
|
|
542
|
+
|
|
543
|
+
// marks to insert AFTER the chunk holding global syllable g:
|
|
544
|
+
const caesAfter = new Map(); // g -> caesura info
|
|
545
|
+
for (const c of line.caesurae) if (c.after > 0) caesAfter.set(c.after - 1, c);
|
|
546
|
+
const footAfter = new Set(); // g -> foot boundary (except line end)
|
|
547
|
+
if (line.feet.length > 1) {
|
|
548
|
+
for (let f = 0; f < line.feet.length - 1; f++) {
|
|
549
|
+
const syls = line.feet[f].cells.filter(c => c.s !== undefined);
|
|
550
|
+
if (syls.length) footAfter.add(syls[syls.length - 1].s);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const markNodes = (g) => {
|
|
555
|
+
const out = [];
|
|
556
|
+
if (!state.showFeet) return out;
|
|
557
|
+
const c = caesAfter.get(g);
|
|
558
|
+
if (c) {
|
|
559
|
+
const { glyph, color, label } = caesuraGlyph(c);
|
|
560
|
+
out.push(el('span', { class: 'caesmark', text: ` ${glyph} `, title: label, style: { color } }));
|
|
561
|
+
} else if (footAfter.has(g)) {
|
|
562
|
+
out.push(el('span', { class: 'footmark', text: '|', title: 'foot boundary' }));
|
|
563
|
+
}
|
|
564
|
+
return out;
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
for (const seg of line.segments) {
|
|
568
|
+
if (seg.t === 'gap') { wrap.append(el('span', { class: 'gap', text: seg.text })); continue; }
|
|
569
|
+
const w = line.words[seg.w];
|
|
570
|
+
const wordSpan = el('span', {
|
|
571
|
+
class: `word ${w.isContent ? 'is-content' : 'is-function'}`,
|
|
572
|
+
'data-s': sIdx, 'data-l': lIdx, 'data-w': seg.w,
|
|
573
|
+
});
|
|
574
|
+
for (const ch of seg.chunks) {
|
|
575
|
+
const syl = w.syls[ch.si] ?? w.syls[0];
|
|
576
|
+
const g = w.firstSyl + ch.si;
|
|
577
|
+
wordSpan.append(el('span', {
|
|
578
|
+
class: `chunk t-${syl?.rel ?? 'w'} l-${Math.min(3, syl?.lex ?? 0)}`,
|
|
579
|
+
'data-g': g, text: ch.text,
|
|
580
|
+
}));
|
|
581
|
+
wordSpan.append(...markNodes(g));
|
|
582
|
+
}
|
|
583
|
+
wrap.append(wordSpan);
|
|
584
|
+
}
|
|
585
|
+
return wrap;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function renderChips(line, sIdx, lIdx) {
|
|
589
|
+
const d = line.detail;
|
|
590
|
+
const chips = el('span', { class: 'vline-chips', title: 'open this line’s detailed scan' });
|
|
591
|
+
|
|
592
|
+
const fam = meterFamily(d.meterName === 'free verse' ? 'free verse' : d.meter);
|
|
593
|
+
const famColor = meterColor(d.meter);
|
|
594
|
+
const meterChip = el('span', { class: 'meter-chip' },
|
|
595
|
+
el('span', { class: 'meter-dot', style: { background: famColor, boxShadow: `0 0 8px ${famColor}66` } }),
|
|
596
|
+
el('span', { style: { color: famColor } }, d.meterName === 'free verse' ? 'free verse' : fam),
|
|
597
|
+
d.footCount > 0 ? el('span', { class: 'fit-pct', text: `×${d.footCount}` }) : null,
|
|
598
|
+
el('span', { class: 'fit-pct', text: `${d.certainty}%` }),
|
|
599
|
+
);
|
|
600
|
+
chips.append(meterChip);
|
|
601
|
+
|
|
602
|
+
const rhymeCluster = el('span', { class: 'rhyme-cluster' });
|
|
603
|
+
if (d.rhyme) {
|
|
604
|
+
for (const iw of d.rhyme.internal ?? []) {
|
|
605
|
+
rhymeCluster.append(el('span', {
|
|
606
|
+
class: 'rhyme-chip', text: `(${iw.letter})`,
|
|
607
|
+
style: { color: letterColor(iw.letter), borderColor: letterColor(iw.letter) + '55' },
|
|
608
|
+
title: `internal rhyme before the caesura: “${iw.word}”${iw.type ? ' — ' + iw.type + ' rhyme' : ''}`,
|
|
609
|
+
}));
|
|
610
|
+
}
|
|
611
|
+
if (d.rhyme.letter && d.rhyme.letter !== '·') {
|
|
612
|
+
rhymeCluster.append(el('span', {
|
|
613
|
+
class: 'rhyme-chip', text: d.rhyme.letter,
|
|
614
|
+
style: { color: letterColor(d.rhyme.letter), borderColor: letterColor(d.rhyme.letter) + '55' },
|
|
615
|
+
title: `end-rhyme “${d.rhyme.endWord}” — scheme letter ${d.rhyme.letter}${d.rhyme.type ? ', ' + d.rhyme.type + ' rhyme' : ''}${d.rhyme.matchedLine != null ? ', first bound at line ' + (d.rhyme.matchedLine + 1) : ''}`,
|
|
616
|
+
}));
|
|
617
|
+
} else if (!(d.rhyme.internal ?? []).length) {
|
|
618
|
+
rhymeCluster.append(el('span', { class: 'rhyme-chip unrhymed', text: '·', title: `“${d.rhyme.endWord}” — unrhymed` }));
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
chips.append(rhymeCluster);
|
|
622
|
+
|
|
623
|
+
chips.append(el('span', { class: 'lab-caret', text: '▸ SCAN', title: 'open this line’s detailed scan' }));
|
|
624
|
+
|
|
625
|
+
const notes = el('span', { class: 'note-cluster' });
|
|
626
|
+
if (d.rhythmNote) notes.append(el('span', { class: 'note-glyph', text: '♪', style: { color: METER_HUE.amphibrachic }, title: d.rhythmNote }));
|
|
627
|
+
if (d.standaloneMeter) notes.append(el('span', { class: 'note-glyph', text: '≈', style: { color: 'var(--heliotrope)' }, title: `continuity: read with the stanza; standalone best fit is ${d.standaloneMeter}` }));
|
|
628
|
+
if (d.consensusMeter) notes.append(el('span', { class: 'note-glyph', text: '↔', style: { color: 'var(--heliotrope)' }, title: `aligns with stanza ${d.consensusMeter}` }));
|
|
629
|
+
if (d.metricalityNote) notes.append(el('span', { class: 'note-glyph', text: '¶', style: { color: 'var(--sepia)' }, title: d.metricalityNote }));
|
|
630
|
+
chips.append(notes);
|
|
631
|
+
chips.addEventListener('click', (e) => { e.stopPropagation(); toggleLab(sIdx, lIdx); });
|
|
632
|
+
return chips;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/* ═══════════════════ The line laboratory ═══════════════════ */
|
|
636
|
+
|
|
637
|
+
function lineRowEl(sIdx, lIdx) {
|
|
638
|
+
return $(`.vline[data-s="${sIdx}"][data-l="${lIdx}"]`, els.codex);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function toggleRussianLab(sIdx, lIdx) {
|
|
642
|
+
const row = lineRowEl(sIdx, lIdx);
|
|
643
|
+
if (!row) return;
|
|
644
|
+
const existing = $('.lab', row);
|
|
645
|
+
if (existing) { existing.remove(); row.classList.remove('is-open'); return; }
|
|
646
|
+
const line = state.data.stanzas[sIdx].lines[lIdx];
|
|
647
|
+
const lab = buildRussianLab(line, sIdx, lIdx);
|
|
648
|
+
row.append(lab);
|
|
649
|
+
row.classList.add('is-open');
|
|
650
|
+
if (line.deps?.length) {
|
|
651
|
+
drawDepArcs(lab, { deps: line.deps, words: line.words.map(w => ({ text: w.form })) });
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const RU_UPOS_GLOSS = {
|
|
656
|
+
NOUN: 'noun', VERB: 'verb', ADJ: 'adjective', ADV: 'adverb',
|
|
657
|
+
PRON: 'pronoun', DET: 'determiner', PROPN: 'proper noun',
|
|
658
|
+
ADP: 'preposition', CCONJ: 'coordinating conjunction', SCONJ: 'subordinating conjunction',
|
|
659
|
+
PART: 'particle', INTJ: 'interjection', NUM: 'numeral', AUX: 'auxiliary', X: 'other',
|
|
660
|
+
};
|
|
661
|
+
|
|
662
|
+
function buildRussianLab(line, sIdx, lIdx) {
|
|
663
|
+
const lab = el('div', { class: 'lab' });
|
|
664
|
+
const gIdx = countLinesBeforeStanza(sIdx) + lIdx; // poem-global line index
|
|
665
|
+
|
|
666
|
+
const meter = state.data.meter;
|
|
667
|
+
const mColor = ruMeterColor(meter.meter);
|
|
668
|
+
const mDef = RU_METER_DEF[meter.meter];
|
|
669
|
+
|
|
670
|
+
/* ── verdict ── */
|
|
671
|
+
lab.append(labHeader('Best reading', 'stress assignment chosen by the Russian meter-mapping algorithm (RPST)'));
|
|
672
|
+
const verdict = el('div', { class: 'verdict' });
|
|
673
|
+
verdict.append(el('span', { class: 'verdict-meter' },
|
|
674
|
+
el('span', { style: { color: mColor } }, mDef?.ru || meter.meterRu || meter.meter),
|
|
675
|
+
meter.footCount > 0 ? el('span', { class: 'foot-ct', text: `${meter.footCount} feet` }) : null,
|
|
676
|
+
));
|
|
677
|
+
verdict.append(el('span', { class: 'fitbar', title: 'technicality: the aligner’s confidence that this is syllabo-tonic verse' },
|
|
678
|
+
el('i', { style: { width: `${Math.round((state.data.score ?? 0) * 100)}%` } })));
|
|
679
|
+
verdict.append(el('span', { class: 'fit-label', text: `${Math.round((state.data.score ?? 0) * 100)}% technicality` }));
|
|
680
|
+
|
|
681
|
+
const notes = el('div', { class: 'verdict-notes' });
|
|
682
|
+
notes.append(el('span', { class: 'verdict-note mono-inline', html: `pattern: <b>${esc(line.stressPattern.replace(/S/g, '¯').replace(/U/g, '˘'))}</b> · ${line.syllableCount} syllables` }));
|
|
683
|
+
const rhyme = state.data.rhymes?.[gIdx];
|
|
684
|
+
if (rhyme && rhyme.letter !== '-') {
|
|
685
|
+
notes.append(el('span', { class: 'verdict-note', html:
|
|
686
|
+
`rhymes “<b>${esc(rhyme.endWord)}</b>” — scheme letter <b style="color:${letterColor(rhyme.letter)}">${esc(rhyme.letter)}</b>${rhyme.matchedLine != null ? `, bound to line ${rhyme.matchedLine + 1}` : ''}` }));
|
|
687
|
+
} else if (rhyme) {
|
|
688
|
+
notes.append(el('span', { class: 'verdict-note', html: `“<b>${esc(rhyme.endWord)}</b>” — unrhymed` }));
|
|
689
|
+
}
|
|
690
|
+
verdict.append(notes);
|
|
691
|
+
lab.append(verdict);
|
|
692
|
+
|
|
693
|
+
/* ── stress track, word by word ── */
|
|
694
|
+
lab.append(labHeader('Stress, word by word', 'aligned reading vs the dictionary: ´ marks the syllable the meter chose; a hollow ring marks a dictionary stress the alignment suppressed'));
|
|
695
|
+
const track = el('div', { class: 'feet-track' });
|
|
696
|
+
line.words.forEach(w => {
|
|
697
|
+
const cell = el('div', { class: 'foot-cell', title: `${w.form} — ${RU_UPOS_GLOSS[w.upos] ?? w.upos}${w.lexStressPos > 0 ? `, dictionary stress on vowel ${w.lexStressPos}` : ''}` });
|
|
698
|
+
let vi = 0;
|
|
699
|
+
for (const syl of w.syllables) {
|
|
700
|
+
if (!syl.vowel) continue;
|
|
701
|
+
vi++;
|
|
702
|
+
const aligned = syl.stressed;
|
|
703
|
+
const lexHere = w.lexStressPos === vi;
|
|
704
|
+
const suppressed = lexHere && !aligned && w.syllables.filter(s => s.vowel).length > 1;
|
|
705
|
+
const tier = aligned ? 's' : syl.secondaryStressed ? 'm' : 'w';
|
|
706
|
+
cell.append(el('span', { class: 'foot-syl', title: `${syl.text} — ${aligned ? 'stressed in this reading' : suppressed ? 'dictionary stress, unstressed in this reading' : 'unstressed'}` },
|
|
707
|
+
el('span', { class: 'fs-text', text: syl.text, style: { color: `var(--tier-${tier})` } }),
|
|
708
|
+
el('span', { class: 'fs-mark', text: aligned ? '´' : suppressed ? '˚' : '˘', style: { color: `var(--tier-${tier})` } })));
|
|
709
|
+
}
|
|
710
|
+
track.append(cell);
|
|
711
|
+
});
|
|
712
|
+
lab.append(track);
|
|
713
|
+
|
|
714
|
+
/* ── syntax: dependency arcs + morphology register ── */
|
|
715
|
+
if (line.deps?.length) {
|
|
716
|
+
lab.append(labHeader('Syntax', 'bonds of reliance — the phrasal dependency tree, from UDPipe (Russian SynTagRus, UPOS)'));
|
|
717
|
+
const stage = el('div', { class: 'dep-stage' });
|
|
718
|
+
stage.append(el('div', { class: 'dep-svg-slot' }));
|
|
719
|
+
const wordsRow = el('div', { class: 'dep-words' });
|
|
720
|
+
line.words.forEach((w, wi) => {
|
|
721
|
+
wordsRow.append(el('div', { class: `dep-word ${w.isContent ? 'is-content' : 'is-function'}`, 'data-w': wi },
|
|
722
|
+
el('span', { class: 'dw-text', text: w.form }),
|
|
723
|
+
el('span', { class: 'dw-pos', text: w.upos, title: RU_UPOS_GLOSS[w.upos] ?? w.upos }),
|
|
724
|
+
));
|
|
725
|
+
});
|
|
726
|
+
stage.append(wordsRow);
|
|
727
|
+
lab.append(stage);
|
|
728
|
+
|
|
729
|
+
const table = el('table', { class: 'syn-table' },
|
|
730
|
+
el('tr', {},
|
|
731
|
+
el('th', { text: 'word' }),
|
|
732
|
+
el('th', { text: 'part of speech', title: 'UPOS from the Russian SynTagRus UDPipe model (no XPOS layer)' }),
|
|
733
|
+
el('th', { text: 'features', title: 'UD morphological FEATS from UDPipe' }),
|
|
734
|
+
el('th', { text: 'grammatical bond', title: 'the dependency relation to its governor' })),
|
|
735
|
+
line.words.map((w, wi) => {
|
|
736
|
+
const dep = line.deps.find(d => d.from === wi);
|
|
737
|
+
const gov = dep && dep.to >= 0 ? line.words[dep.to] : null;
|
|
738
|
+
return el('tr', {},
|
|
739
|
+
el('td', { class: 'w', text: w.form }),
|
|
740
|
+
el('td', {}, el('abbr', { text: w.upos, title: RU_UPOS_GLOSS[w.upos] ?? w.upos })),
|
|
741
|
+
el('td', {}, w.feats && Object.keys(w.feats).length
|
|
742
|
+
? Object.entries(w.feats).map(([k, v]) =>
|
|
743
|
+
el('span', { class: 'feat-chip', title: `${k}=${v}`, html: `${esc(k)}: <b>${esc(v)}</b>` }))
|
|
744
|
+
: el('span', { text: '—' })),
|
|
745
|
+
el('td', {}, dep
|
|
746
|
+
? el('abbr', {
|
|
747
|
+
text: dep.to === -1 ? 'root' : `←${dep.rel}← ${gov?.form ?? '?'}`,
|
|
748
|
+
title: dep.to === -1 ? (DEP_GLOSS.root ?? 'root') : `${DEP_GLOSS[dep.rel] ?? dep.rel} “${gov?.form ?? '?'}”`,
|
|
749
|
+
})
|
|
750
|
+
: el('span', { text: '—' })));
|
|
751
|
+
}));
|
|
752
|
+
lab.append(table);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/* ── Fabb–Halle bracketed grid ── */
|
|
756
|
+
if (line.fabbHalle) {
|
|
757
|
+
const fh = line.fabbHalle;
|
|
758
|
+
lab.append(labHeader('Fabb–Halle bracketed grid', 'Meter in Poetry (2008): counting-first gridline scansion — * projects, ( groups rightward, ) leftward'));
|
|
759
|
+
const wrap = el('div', { class: 'fh-grid' });
|
|
760
|
+
wrap.append(el('div', {
|
|
761
|
+
text: fh.ruleLabel,
|
|
762
|
+
style: { color: 'var(--whisper)', fontSize: '12px', marginBottom: '4px' },
|
|
763
|
+
}));
|
|
764
|
+
|
|
765
|
+
const texts = line.words.flatMap(w => w.syllables.filter(s => s.vowel).map(s => s.text));
|
|
766
|
+
const colW = texts.map((t, i) =>
|
|
767
|
+
Math.max((t || '').length, ...fh.rows.map(r => (r[i] || '').length)) + 1);
|
|
768
|
+
const padTo = (t, w) => t + ' '.repeat(Math.max(0, w - t.length));
|
|
769
|
+
const maxSet = new Set(fh.maxima || []);
|
|
770
|
+
const violSet = new Set(fh.violations || []);
|
|
771
|
+
const sylRow = texts.map((t, i) => {
|
|
772
|
+
const cell = esc(padTo(t || '', colW[i]));
|
|
773
|
+
if (violSet.has(i)) return `<span style="color:#e06c5a">${cell}</span>`;
|
|
774
|
+
if (maxSet.has(i)) return `<span style="color:#d4b653">${cell}</span>`;
|
|
775
|
+
return cell;
|
|
776
|
+
}).join('');
|
|
777
|
+
let gridHtml = `<span style="opacity:.55">Syl: </span>${sylRow}\n`;
|
|
778
|
+
fh.rows.forEach((row, g) => {
|
|
779
|
+
gridHtml += `<span style="opacity:.55">G${g}: </span>` +
|
|
780
|
+
esc(row.map((m, i) => padTo(m || '', colW[i])).join('')) + '\n';
|
|
781
|
+
});
|
|
782
|
+
wrap.append(el('pre', {
|
|
783
|
+
html: gridHtml,
|
|
784
|
+
style: {
|
|
785
|
+
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
|
786
|
+
fontSize: '13px',
|
|
787
|
+
overflowX: 'auto',
|
|
788
|
+
}
|
|
789
|
+
}));
|
|
790
|
+
|
|
791
|
+
const fhVerdict = fh.looseFeet != null
|
|
792
|
+
? `${fh.looseFeet} feet — in a loose meter (dol’nik) the foot count IS the meter; maxima anchor the feet, unfooted syllables are free`
|
|
793
|
+
: fh.maxima.length === 0
|
|
794
|
+
? 'no maxima in this line — vacuously metrical'
|
|
795
|
+
: fh.violations.length === 0
|
|
796
|
+
? `${fh.maxima.length} maxim${fh.maxima.length === 1 ? 'um' : 'a'} (polysyllable stresses — Taranovsky’s law), all land on ictuses → metrical`
|
|
797
|
+
: `${fh.violations.length} of ${fh.maxima.length} maxima miss the ictus → unmetrical under these rules`;
|
|
798
|
+
wrap.append(el('div', {
|
|
799
|
+
text: fhVerdict,
|
|
800
|
+
style: { fontSize: '12px', color: fh.metrical ? '#7fb069' : '#e06c5a' },
|
|
801
|
+
}));
|
|
802
|
+
|
|
803
|
+
lab.append(wrap);
|
|
804
|
+
} else {
|
|
805
|
+
lab.append(el('p', { class: 'whisper', text: 'No Fabb–Halle grid for this line — the verdict names no strict meter (free verse), and F&H parses only against a stipulated rule set.' }));
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
return lab;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function toggleLab(sIdx, lIdx) {
|
|
812
|
+
const row = lineRowEl(sIdx, lIdx);
|
|
813
|
+
if (!row) return;
|
|
814
|
+
const existing = $('.lab', row);
|
|
815
|
+
if (existing) { existing.remove(); row.classList.remove('is-open'); return; }
|
|
816
|
+
const line = state.data.stanzas[sIdx].lines[lIdx];
|
|
817
|
+
const lab = buildLab(line, sIdx, lIdx);
|
|
818
|
+
row.append(lab);
|
|
819
|
+
row.classList.add('is-open');
|
|
820
|
+
drawDepArcs(lab, line);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function labHeader(title, sub) {
|
|
824
|
+
return el('h4', {}, title, sub ? el('span', { class: 'whisper', text: sub }) : null);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function buildLab(line, sIdx, lIdx) {
|
|
828
|
+
const d = line.detail;
|
|
829
|
+
const lab = el('div', { class: 'lab' });
|
|
830
|
+
|
|
831
|
+
/* ── verdict ── */
|
|
832
|
+
lab.append(labHeader('Best reading', 'what meter this line is, and how surely'));
|
|
833
|
+
const famColor = meterColor(d.meter);
|
|
834
|
+
const verdict = el('div', { class: 'verdict' });
|
|
835
|
+
verdict.append(el('span', { class: 'verdict-meter' },
|
|
836
|
+
el('span', { style: { color: famColor } }, d.meter),
|
|
837
|
+
d.footCount > 0 ? el('span', { class: 'foot-ct', text: `${d.footCount} feet` }) : null,
|
|
838
|
+
));
|
|
839
|
+
verdict.append(el('span', { class: 'fitbar', title: 'fit: the share of the line realized by clean, unsubstituted feet' },
|
|
840
|
+
el('i', { style: { width: `${d.certainty}%` } })));
|
|
841
|
+
verdict.append(el('span', { class: 'fit-label', text: `${d.certainty}% fit` }));
|
|
842
|
+
|
|
843
|
+
const notes = el('div', { class: 'verdict-notes' });
|
|
844
|
+
if (d.standaloneMeter) notes.append(el('span', { class: 'verdict-note', html: `≈ read with the stanza for continuity — on its own this line fits <b>${esc(d.standaloneMeter)}</b> a hair better.` }));
|
|
845
|
+
if (d.consensusMeter) notes.append(el('span', { class: 'verdict-note', html: `↔ compatible with the stanza’s <b>${esc(d.consensusMeter)}</b>.` }));
|
|
846
|
+
if (d.rhythmNote) notes.append(el('span', { class: 'verdict-note rhythm', html: `♪ ${esc(d.rhythmNote)} — a steady count of strong beats over a varying syllable count.` }));
|
|
847
|
+
if (d.metricalityNote) notes.append(el('span', { class: 'verdict-note hedge', text: '¶ ' + d.metricalityNote }));
|
|
848
|
+
if (d.rhyme && d.rhyme.letter !== '·' && d.rhyme.matchedLine != null) {
|
|
849
|
+
notes.append(el('span', { class: 'verdict-note', html: `rhymes “<b>${esc(d.rhyme.endWord)}</b>” with line ${d.rhyme.matchedLine + 1}${d.rhyme.type ? ` — a <b>${esc(d.rhyme.type)}</b> rhyme` : ''}.` }));
|
|
850
|
+
}
|
|
851
|
+
if (notes.children.length) verdict.append(notes);
|
|
852
|
+
lab.append(verdict);
|
|
853
|
+
|
|
854
|
+
/* ── candidate ranking ── */
|
|
855
|
+
if (d.ranking?.length) {
|
|
856
|
+
lab.append(labHeader('Candidate meters', 'all seven auditioned — in the engine’s ranked order (arbitration can outrank a raw score)'));
|
|
857
|
+
const maxScore = Math.max(...d.ranking.map(r => r.score), 0.001);
|
|
858
|
+
const rankBox = el('div', { class: 'ranking' });
|
|
859
|
+
d.ranking.slice(0, 5).forEach(r => {
|
|
860
|
+
rankBox.append(el('div', { class: 'rank-row' },
|
|
861
|
+
el('span', { class: 'rank-name', text: r.meter, style: { color: meterColor(r.meter) } }),
|
|
862
|
+
el('span', { class: 'rank-bar' }, el('i', { style: { width: `${(r.score / maxScore) * 100}%`, background: meterColor(r.meter) } })),
|
|
863
|
+
el('span', { class: 'rank-score', text: r.score.toFixed(2) }),
|
|
864
|
+
));
|
|
865
|
+
});
|
|
866
|
+
lab.append(rankBox);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/* ── feet ── */
|
|
870
|
+
lab.append(labHeader('Scansion, foot by foot', `map: ${d.scansion}`));
|
|
871
|
+
lab.append(buildFeetTrack(line));
|
|
872
|
+
|
|
873
|
+
/* ── syllable matrix ── */
|
|
874
|
+
lab.append(labHeader('Syllable anatomy', 'every layer of every syllable — hover the poem for the same data'));
|
|
875
|
+
lab.append(buildMatrix(line));
|
|
876
|
+
|
|
877
|
+
/* ── prosodic phrasing ── */
|
|
878
|
+
lab.append(labHeader('Prosodic phrasing', 'how speech strands & braids the words: κ clitic group · ϕ phrase · ι intonational unit'));
|
|
879
|
+
lab.append(buildBracketing(line));
|
|
880
|
+
lab.append(buildHierTree(line));
|
|
881
|
+
|
|
882
|
+
/* ── key stresses ── */
|
|
883
|
+
if (line.keyStresses?.length) {
|
|
884
|
+
lab.append(labHeader('Key stresses', 'right-edge evidence, weighted by unit size — hover to see where'));
|
|
885
|
+
const ksRow = el('div', { class: 'keystress-row' });
|
|
886
|
+
const UNIT_NAME = { IU: 'intonational unit', PP: 'phrase', CP: 'clitic group', PW: 'word' };
|
|
887
|
+
for (const ks of line.keyStresses) {
|
|
888
|
+
const chip = el('span', { class: 'ks-chip', title: `end of a ${UNIT_NAME[ks.unitType] ?? ks.unitType} — weight ${ks.weight}` },
|
|
889
|
+
el('span', { class: 'kst', text: ks.unitType, style: { color: ks.unitType === 'IU' ? 'var(--viridian)' : ks.unitType === 'PP' ? 'var(--blue)' : ks.unitType === 'CP' ? 'var(--rose)' : 'var(--sepia)' } }),
|
|
890
|
+
el('span', { text: ks.pattern }),
|
|
891
|
+
el('span', { class: 'ksw', text: `×${ks.weight}` }),
|
|
892
|
+
);
|
|
893
|
+
chip.addEventListener('mouseenter', () => highlightSyls(sIdx, lIdx, ks.positions, true));
|
|
894
|
+
chip.addEventListener('mouseleave', () => highlightSyls(sIdx, lIdx, ks.positions, false));
|
|
895
|
+
ksRow.append(chip);
|
|
896
|
+
}
|
|
897
|
+
lab.append(ksRow);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/* ── syntax ── */
|
|
901
|
+
if (line.deps?.length) {
|
|
902
|
+
lab.append(labHeader('Syntax', 'bonds of reliance — the phrasal dependency tree, from UDPipe'));
|
|
903
|
+
const stage = el('div', { class: 'dep-stage' });
|
|
904
|
+
stage.append(el('div', { class: 'dep-svg-slot' }));
|
|
905
|
+
const wordsRow = el('div', { class: 'dep-words' });
|
|
906
|
+
line.words.forEach(w => {
|
|
907
|
+
wordsRow.append(el('div', { class: `dep-word ${w.isContent ? 'is-content' : 'is-function'}`, 'data-w': w.i },
|
|
908
|
+
el('span', { class: 'dw-text', text: w.text }),
|
|
909
|
+
el('span', { class: 'dw-pos', text: w.pos, title: POS_GLOSS[w.pos] ?? w.pos }),
|
|
910
|
+
));
|
|
911
|
+
});
|
|
912
|
+
stage.append(wordsRow);
|
|
913
|
+
lab.append(stage);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/* ── scandroid (fully independent second opinion) ── */
|
|
917
|
+
if (line.scandroid) {
|
|
918
|
+
const sd = line.scandroid;
|
|
919
|
+
lab.append(labHeader('Scandroid’s second opinion', 'Charles Hartman’s classic scanner — its own dictionary, syllabifier, and algorithms, reading only the raw line'));
|
|
920
|
+
const rows = el('div', { class: 'scandroid-rows' });
|
|
921
|
+
const lengthTxt = sd.lineFeetSet ? sd.lineLengthName : `${sd.lineLengthName} (variable)`;
|
|
922
|
+
rows.append(el('div', { html: `<b>Metron</b> · ${esc(sd.metronName)} ${esc(lengthTxt)}` }));
|
|
923
|
+
const footRow = (label, f) => {
|
|
924
|
+
if (!f) return;
|
|
925
|
+
const failTag = f.ok ? '' : ` <span class="scandroid-fail">FAIL${f.failReason ? ` (${esc(f.failReason)})` : ''}</span>`;
|
|
926
|
+
rows.append(el('div', { html: `<b>${esc(label)}</b> · ${esc(f.scanString)}${failTag}` }));
|
|
927
|
+
};
|
|
928
|
+
footRow('Verdict', sd.verdict);
|
|
929
|
+
if (sd.verdict) rows.append(el('div', { class: 'scandroid-marks', html: `marks · ${esc(sd.verdict.marksString)} · ${sd.verdict.substitutions} substitution(s)` }));
|
|
930
|
+
footRow('Corral the Weird', sd.corralTheWeird);
|
|
931
|
+
footRow('Maximize the Normal', sd.maximizeTheNormal);
|
|
932
|
+
lab.append(rows);
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/* ── Fabb–Halle bracketed grid ── */
|
|
936
|
+
if (line.fabbHalle) {
|
|
937
|
+
const fh = line.fabbHalle;
|
|
938
|
+
lab.append(labHeader('Fabb–Halle bracketed grid', 'Meter in Poetry (2008): counting-first gridline scansion — * projects, ( groups rightward, ) leftward'));
|
|
939
|
+
const wrap = el('div', { class: 'fh-grid' });
|
|
940
|
+
wrap.append(el('div', {
|
|
941
|
+
text: fh.rule,
|
|
942
|
+
style: { color: 'var(--whisper, #888)', fontSize: '12px', marginBottom: '4px' },
|
|
943
|
+
}));
|
|
944
|
+
const texts = line.syllables.map(s => s.text);
|
|
945
|
+
const colW = texts.map((t, i) =>
|
|
946
|
+
Math.max((t || '').length, ...fh.rows.map(r => (r[i] || '').length)) + 1);
|
|
947
|
+
const padTo = (t, w) => t + ' '.repeat(Math.max(0, w - t.length));
|
|
948
|
+
const maxSet = new Set(fh.maxima);
|
|
949
|
+
const violSet = new Set(fh.violations);
|
|
950
|
+
const sylRow = texts.map((t, i) => {
|
|
951
|
+
const cell = esc(padTo(t || '', colW[i]));
|
|
952
|
+
if (violSet.has(i)) return `<span style="color:#e06c5a">${cell}</span>`;
|
|
953
|
+
if (maxSet.has(i)) return `<span style="color:#d4b653">${cell}</span>`;
|
|
954
|
+
return cell;
|
|
955
|
+
}).join('');
|
|
956
|
+
let gridHtml = `<span style="opacity:.55">Syl: </span>${sylRow}\n`;
|
|
957
|
+
fh.rows.forEach((row, g) => {
|
|
958
|
+
gridHtml += `<span style="opacity:.55">G${g}: </span>` +
|
|
959
|
+
esc(row.map((m, i) => padTo(m || '', colW[i])).join('')) + '\n';
|
|
960
|
+
});
|
|
961
|
+
wrap.append(el('pre', {
|
|
962
|
+
html: gridHtml,
|
|
963
|
+
style: {
|
|
964
|
+
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
|
965
|
+
fontSize: '12px', lineHeight: '1.5', overflowX: 'auto',
|
|
966
|
+
margin: '4px 0', whiteSpace: 'pre',
|
|
967
|
+
},
|
|
968
|
+
}));
|
|
969
|
+
const verdict = fh.looseFeet != null
|
|
970
|
+
? `${fh.looseFeet} feet — in a loose meter the foot count IS the meter; maxima anchor the feet, unfooted syllables are free`
|
|
971
|
+
: fh.maxima.length === 0
|
|
972
|
+
? 'no maxima in this line — vacuously metrical'
|
|
973
|
+
: fh.violations.length === 0
|
|
974
|
+
? `${fh.maxima.length} maxim${fh.maxima.length === 1 ? 'um' : 'a'}, all project to gridline 1 → metrical`
|
|
975
|
+
: `${fh.violations.length} of ${fh.maxima.length} maxima fail to project → unmetrical under these rules`;
|
|
976
|
+
wrap.append(el('div', {
|
|
977
|
+
text: verdict,
|
|
978
|
+
style: { fontSize: '12px', color: fh.metrical ? '#7fb069' : '#e06c5a' },
|
|
979
|
+
}));
|
|
980
|
+
lab.append(wrap);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
return lab;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
function buildFeetTrack(line) {
|
|
987
|
+
const track = el('div', { class: 'feet-track' });
|
|
988
|
+
const caesAtCount = new Map(line.caesurae.map(c => [c.after, c]));
|
|
989
|
+
let count = 0;
|
|
990
|
+
line.feet.forEach((foot, fi) => {
|
|
991
|
+
const cell = el('div', { class: 'foot-cell', title: `foot ${fi + 1}: ${foot.pattern}` });
|
|
992
|
+
for (const c of foot.cells) {
|
|
993
|
+
if (c.silent) {
|
|
994
|
+
cell.append(el('span', { class: 'foot-syl silent', title: 'silent beat — a rest inserted to part two clashing stresses' },
|
|
995
|
+
el('span', { class: 'fs-text', text: '·' }), el('span', { class: 'fs-mark', text: '—', style: { color: 'var(--whisper)' } })));
|
|
996
|
+
continue;
|
|
997
|
+
}
|
|
998
|
+
const syl = line.syllables[c.s];
|
|
999
|
+
count++;
|
|
1000
|
+
const color = `var(--tier-${syl?.rel ?? 'w'})`;
|
|
1001
|
+
cell.append(el('span', { class: 'foot-syl', title: syl ? `${TIERS[syl.rel]?.name} · ${syl.phones}` : '' },
|
|
1002
|
+
el('span', { class: 'fs-text', text: syl?.text ?? '?', style: { color } }),
|
|
1003
|
+
el('span', { class: 'fs-mark', text: c.mark, style: { color } })));
|
|
1004
|
+
}
|
|
1005
|
+
track.append(cell);
|
|
1006
|
+
const caes = caesAtCount.get(count);
|
|
1007
|
+
if (caes) {
|
|
1008
|
+
const { glyph, color, label } = caesuraGlyph(caes);
|
|
1009
|
+
track.append(el('span', { class: 'caes-cell', text: glyph, title: label, style: { color } }));
|
|
1010
|
+
} else if (fi < line.feet.length - 1) {
|
|
1011
|
+
track.append(el('span', { class: 'foot-sep', text: '|' }));
|
|
1012
|
+
}
|
|
1013
|
+
});
|
|
1014
|
+
return track;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function buildMatrix(line) {
|
|
1018
|
+
const wrap = el('div', { class: 'matrix-wrap' });
|
|
1019
|
+
const table = el('table', { class: 'matrix' });
|
|
1020
|
+
|
|
1021
|
+
const trWord = el('tr', {}, el('th', { class: 'rowlab', text: 'word' }));
|
|
1022
|
+
const trPhrase = el('tr', {}, el('th', { class: 'rowlab', text: 'phrase rank', title: 'cyclic compound + nuclear stress: 1 = the utterance’s strongest word' }));
|
|
1023
|
+
line.words.forEach(w => {
|
|
1024
|
+
const span = Math.max(1, w.syls.length);
|
|
1025
|
+
trWord.append(el('td', { class: 'wordhead', colspan: span },
|
|
1026
|
+
w.text, el('span', { class: 'pos', text: w.pos, title: POS_GLOSS[w.pos] ?? '' })));
|
|
1027
|
+
const ps = w.phraseStress || 0;
|
|
1028
|
+
const psColor = ps === 1 ? 'var(--green-hi)' : ps > 0 && ps <= 3 ? 'var(--green)' : 'var(--whisper)';
|
|
1029
|
+
trPhrase.append(el('td', { colspan: span, text: ps === 0 ? '—' : String(ps), style: { color: psColor }, title: ps === 1 ? 'the strongest word of the utterance' : ps === 0 ? 'no phrase stress' : `phrase-prominence rank ${ps} (1 = strongest)` }));
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
const rows = {
|
|
1033
|
+
syl: el('tr', {}, el('th', { class: 'rowlab', text: 'syllable' })),
|
|
1034
|
+
phones: el('tr', {}, el('th', { class: 'rowlab', text: 'phones', title: 'ARPAbet transcription from the CMU dictionary (via Nounsing Pro)' })),
|
|
1035
|
+
weight: el('tr', {}, el('th', { class: 'rowlab', text: 'weight', title: 'H = heavy syllable (long vowel or closed), L = light' })),
|
|
1036
|
+
lex: el('tr', {}, el('th', { class: 'rowlab', text: 'lexical', title: 'dictionary stress: 0 none · 1 secondary · 2 primary · 3+ boosted' })),
|
|
1037
|
+
rel: el('tr', {}, el('th', { class: 'rowlab', text: 'relative', title: 'the five-tier phonological scale after phrase rules: x w n m s' })),
|
|
1038
|
+
};
|
|
1039
|
+
const LEXCOL = { 0: '#5c6da0', 1: '#7a4fa0', 2: '#b3402e', 3: '#8c1015' };
|
|
1040
|
+
line.words.forEach(w => {
|
|
1041
|
+
w.syls.forEach(s => {
|
|
1042
|
+
const g = w.firstSyl + s.si;
|
|
1043
|
+
rows.syl.append(el('td', { class: 'syltext', 'data-g': g, text: s.text || '·' }));
|
|
1044
|
+
rows.phones.append(el('td', { class: 'phones', 'data-g': g, text: s.phones.replace(/[()]/g, '') }));
|
|
1045
|
+
rows.weight.append(el('td', { 'data-g': g, text: s.weight ?? '—', title: s.weight === 'H' ? 'heavy' : s.weight === 'L' ? 'light' : '' }));
|
|
1046
|
+
rows.lex.append(el('td', { 'data-g': g, text: String(s.lex), style: { color: LEXCOL[Math.min(3, s.lex)] }, title: LEX_NAMES[Math.min(3, s.lex)] }));
|
|
1047
|
+
rows.rel.append(el('td', { 'data-g': g, text: s.rel, style: { color: `var(--tier-${s.rel})`, fontWeight: 600 }, title: TIERS[s.rel]?.name }));
|
|
1048
|
+
});
|
|
1049
|
+
if (!w.syls.length) {
|
|
1050
|
+
rows.syl.append(el('td', { text: '—' })); rows.phones.append(el('td', { text: '' }));
|
|
1051
|
+
rows.weight.append(el('td', { text: '' })); rows.lex.append(el('td', { text: '' })); rows.rel.append(el('td', { text: '' }));
|
|
1052
|
+
}
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
table.append(trWord, rows.syl, rows.phones, rows.weight, rows.lex, rows.rel, trPhrase);
|
|
1056
|
+
wrap.append(table);
|
|
1057
|
+
return wrap;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
function buildBracketing(line) {
|
|
1061
|
+
const box = el('div', { class: 'bracketing' });
|
|
1062
|
+
const KAPPA_COLOR = '#7a8fc9';
|
|
1063
|
+
const key = (m, lvl) => !m ? 'x' : lvl === 'cp' ? `${m.iu}.${m.pp}.${m.cp}` : lvl === 'pp' ? `${m.iu}.${m.pp}` : `${m.iu}`;
|
|
1064
|
+
let phiOrd = -1;
|
|
1065
|
+
let phiColor = 'var(--heliotrope)';
|
|
1066
|
+
let iotaColor = 'var(--viridian)';
|
|
1067
|
+
|
|
1068
|
+
line.words.forEach((w, wi) => {
|
|
1069
|
+
const m = line.unitOf[wi];
|
|
1070
|
+
const prev = wi > 0 ? line.unitOf[wi - 1] : null;
|
|
1071
|
+
const next = wi < line.words.length - 1 ? line.unitOf[wi + 1] : null;
|
|
1072
|
+
const firstPP = !prev || key(prev, 'pp') !== key(m, 'pp');
|
|
1073
|
+
const firstIU = !prev || key(prev, 'iu') !== key(m, 'iu');
|
|
1074
|
+
const firstCP = !prev || key(prev, 'cp') !== key(m, 'cp');
|
|
1075
|
+
const lastPP = !next || key(next, 'pp') !== key(m, 'pp');
|
|
1076
|
+
const lastIU = !next || key(next, 'iu') !== key(m, 'iu');
|
|
1077
|
+
const lastCP = !next || key(next, 'cp') !== key(m, 'cp');
|
|
1078
|
+
|
|
1079
|
+
if (firstPP) {
|
|
1080
|
+
phiOrd++;
|
|
1081
|
+
const st = line.boundaries?.phi?.[phiOrd]?.strength ?? 0;
|
|
1082
|
+
phiColor = grad(st);
|
|
1083
|
+
if (firstIU) iotaColor = phiColor;
|
|
1084
|
+
}
|
|
1085
|
+
if (wi > 0) box.append(' ');
|
|
1086
|
+
if (firstIU) box.append(el('span', { class: 'br', text: '<', style: { color: iotaColor }, title: 'ι — intonational unit begins' }));
|
|
1087
|
+
if (firstPP) box.append(el('span', { class: 'br', text: '{', style: { color: phiColor }, title: 'ϕ — phonological phrase begins (tint = boundary strength)' }));
|
|
1088
|
+
if (firstCP) box.append(el('span', { class: 'br', text: '[', style: { color: KAPPA_COLOR }, title: 'κ — clitic group: a content word plus its little satellites' }));
|
|
1089
|
+
|
|
1090
|
+
const wordSpan = el('span', {});
|
|
1091
|
+
w.syls.forEach(s => wordSpan.append(el('span', { text: s.text, style: { color: `var(--tier-${s.rel})` } })));
|
|
1092
|
+
if (!w.syls.length) wordSpan.append(w.text);
|
|
1093
|
+
box.append(wordSpan);
|
|
1094
|
+
|
|
1095
|
+
if (lastCP) box.append(el('span', { class: 'br', text: ']', style: { color: KAPPA_COLOR } }));
|
|
1096
|
+
if (lastPP) box.append(el('span', { class: 'br', text: '}', style: { color: phiColor } }));
|
|
1097
|
+
if (lastIU) box.append(el('span', { class: 'br', text: '>', style: { color: iotaColor } }));
|
|
1098
|
+
});
|
|
1099
|
+
return box;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
function buildHierTree(line) {
|
|
1103
|
+
const tree = el('div', { class: 'hier-tree' });
|
|
1104
|
+
line.hierarchy.forEach((iu, ii) => {
|
|
1105
|
+
const iuRow = el('div', { class: 'iu-row' }, el('span', { class: 'unit-tag iu', text: `ι ${ii + 1}` }));
|
|
1106
|
+
iu.pps.forEach((pp, pi) => {
|
|
1107
|
+
const ppRow = el('div', { class: 'pp-row' }, el('span', { class: 'unit-tag pp', text: `ϕ ${ii + 1}.${pi + 1}` }));
|
|
1108
|
+
pp.cps.forEach((cp) => {
|
|
1109
|
+
const cpSpan = el('span', { class: 'cp-inline' }, el('span', { class: 'unit-tag cp', text: 'κ' }));
|
|
1110
|
+
cp.forEach(wi => {
|
|
1111
|
+
const w = line.words[wi];
|
|
1112
|
+
if (w) cpSpan.append(el('span', { class: 'cw', text: w.text, style: { fontStyle: w.isContent ? 'normal' : 'italic', color: w.isContent ? 'var(--ink)' : 'var(--sepia)' } }));
|
|
1113
|
+
});
|
|
1114
|
+
ppRow.append(cpSpan);
|
|
1115
|
+
});
|
|
1116
|
+
iuRow.append(ppRow);
|
|
1117
|
+
});
|
|
1118
|
+
tree.append(iuRow);
|
|
1119
|
+
});
|
|
1120
|
+
return tree;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
function highlightSyls(sIdx, lIdx, positions, on) {
|
|
1124
|
+
const row = lineRowEl(sIdx, lIdx);
|
|
1125
|
+
if (!row) return;
|
|
1126
|
+
for (const p of positions) {
|
|
1127
|
+
$$(`[data-g="${p}"]`, row).forEach(n => n.classList.toggle('hl', on));
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
/* ── dependency arcs (SVG drawn after layout) ── */
|
|
1132
|
+
|
|
1133
|
+
function drawDepArcs(lab, line) {
|
|
1134
|
+
const stage = $('.dep-stage', lab);
|
|
1135
|
+
if (!stage) return;
|
|
1136
|
+
const slot = $('.dep-svg-slot', stage);
|
|
1137
|
+
const wordsRow = $('.dep-words', stage);
|
|
1138
|
+
const wordEls = $$('.dep-word', wordsRow);
|
|
1139
|
+
if (!wordEls.length) return;
|
|
1140
|
+
|
|
1141
|
+
const stageRect = wordsRow.getBoundingClientRect();
|
|
1142
|
+
const centers = wordEls.map(we => {
|
|
1143
|
+
const r = we.getBoundingClientRect();
|
|
1144
|
+
return r.left - stageRect.left + r.width / 2;
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
const arcs = line.deps.filter(dp => dp.to >= 0 && dp.from !== dp.to && centers[dp.from] != null && centers[dp.to] != null);
|
|
1148
|
+
const root = line.deps.find(dp => dp.to === -1);
|
|
1149
|
+
|
|
1150
|
+
// stack heights: shorter spans lower
|
|
1151
|
+
const spans = arcs.map(a => Math.abs(a.from - a.to));
|
|
1152
|
+
const H = 30 + Math.min(4, Math.max(...spans, 1)) * 16;
|
|
1153
|
+
const svgNS = 'http://www.w3.org/2000/svg';
|
|
1154
|
+
const svg = document.createElementNS(svgNS, 'svg');
|
|
1155
|
+
svg.setAttribute('class', 'dep-svg');
|
|
1156
|
+
svg.setAttribute('width', String(Math.max(wordsRow.scrollWidth, stageRect.width)));
|
|
1157
|
+
svg.setAttribute('height', String(H + 26)); // clear air between arrowheads and the words
|
|
1158
|
+
|
|
1159
|
+
const ARC_COLORS = document.body.classList.contains('theme-terminal')
|
|
1160
|
+
? ['#b39df1', '#46d17e', '#57a7ff', '#ff85c0', '#f0a848', '#3ecfe0']
|
|
1161
|
+
: ['#5d3f8e', '#1f6b47', '#2f5e9e', '#a04a78', '#a06818', '#0f7a8a'];
|
|
1162
|
+
arcs.forEach((a, i) => {
|
|
1163
|
+
const x1 = centers[a.from], x2 = centers[a.to];
|
|
1164
|
+
const span = Math.abs(a.from - a.to);
|
|
1165
|
+
const h = Math.min(H - 6, 18 + span * 15);
|
|
1166
|
+
const y = H + 8;
|
|
1167
|
+
const midX = (x1 + x2) / 2;
|
|
1168
|
+
const color = ARC_COLORS[i % ARC_COLORS.length];
|
|
1169
|
+
|
|
1170
|
+
const path = document.createElementNS(svgNS, 'path');
|
|
1171
|
+
path.setAttribute('d', `M ${x1} ${y} C ${x1} ${y - h}, ${x2} ${y - h}, ${x2} ${y}`);
|
|
1172
|
+
path.setAttribute('class', 'dep-arc');
|
|
1173
|
+
path.setAttribute('stroke', color);
|
|
1174
|
+
svg.append(path);
|
|
1175
|
+
|
|
1176
|
+
// arrowhead at the DEPENDENT end (x1)
|
|
1177
|
+
const arrow = document.createElementNS(svgNS, 'path');
|
|
1178
|
+
arrow.setAttribute('d', `M ${x1 - 3.4} ${y - 6} L ${x1} ${y} L ${x1 + 3.4} ${y - 6}`);
|
|
1179
|
+
arrow.setAttribute('fill', 'none');
|
|
1180
|
+
arrow.setAttribute('stroke', color);
|
|
1181
|
+
arrow.setAttribute('stroke-width', '1.4');
|
|
1182
|
+
svg.append(arrow);
|
|
1183
|
+
|
|
1184
|
+
const label = document.createElementNS(svgNS, 'text');
|
|
1185
|
+
label.setAttribute('x', String(midX));
|
|
1186
|
+
label.setAttribute('y', String(y - h * 0.75 - 4));
|
|
1187
|
+
label.setAttribute('text-anchor', 'middle');
|
|
1188
|
+
label.setAttribute('class', 'dep-arc-label');
|
|
1189
|
+
label.setAttribute('fill', color);
|
|
1190
|
+
label.textContent = a.rel;
|
|
1191
|
+
const title = document.createElementNS(svgNS, 'title');
|
|
1192
|
+
title.textContent = `“${line.words[a.from]?.text}” is ${DEP_GLOSS[a.rel] ?? a.rel} “${line.words[a.to]?.text}”`;
|
|
1193
|
+
label.append(title);
|
|
1194
|
+
svg.append(label);
|
|
1195
|
+
});
|
|
1196
|
+
|
|
1197
|
+
if (root && centers[root.from] != null) {
|
|
1198
|
+
const x = centers[root.from];
|
|
1199
|
+
const mark = document.createElementNS(svgNS, 'text');
|
|
1200
|
+
mark.setAttribute('x', String(x));
|
|
1201
|
+
mark.setAttribute('y', '12');
|
|
1202
|
+
mark.setAttribute('text-anchor', 'middle');
|
|
1203
|
+
mark.setAttribute('class', 'dep-root-mark');
|
|
1204
|
+
mark.textContent = '▾ root';
|
|
1205
|
+
svg.append(mark);
|
|
1206
|
+
const stem = document.createElementNS(svgNS, 'line');
|
|
1207
|
+
stem.setAttribute('x1', String(x)); stem.setAttribute('x2', String(x));
|
|
1208
|
+
stem.setAttribute('y1', '16'); stem.setAttribute('y2', String(H + 8));
|
|
1209
|
+
stem.style.stroke = 'var(--viridian)'; stem.setAttribute('stroke-dasharray', '2 3');
|
|
1210
|
+
svg.append(stem);
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
slot.innerHTML = '';
|
|
1214
|
+
slot.append(svg);
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
/* ═══════════════════ Rail: synopsis · phonopoetics · inspector ═══════════════════ */
|
|
1218
|
+
|
|
1219
|
+
function renderSynopsis() {
|
|
1220
|
+
const { synopsis, enjambment, engine, elapsedMs } = state.data;
|
|
1221
|
+
const card = els.synopsis;
|
|
1222
|
+
card.innerHTML = '';
|
|
1223
|
+
card.append(el('h3', { class: 'rail-title' }, 'Poem Synopsis'));
|
|
1224
|
+
const rows = [...(synopsis ?? [])];
|
|
1225
|
+
if (enjambment) rows.push({ label: 'Enjambment', value: enjambment });
|
|
1226
|
+
if (!rows.length) {
|
|
1227
|
+
card.append(el('p', { class: 'whisper', text: 'A single line has no poem-wide story — paste a few more.' }));
|
|
1228
|
+
}
|
|
1229
|
+
for (const r of rows) {
|
|
1230
|
+
if (/scheme/i.test(r.label)) { // rhyme-scheme strings: colored letters, wrapped, never overflowing
|
|
1231
|
+
card.append(el('div', { class: 'synopsis-row' },
|
|
1232
|
+
el('span', { class: 'lab-l', text: r.label }),
|
|
1233
|
+
el('span', { class: 'lab-v scheme-letters' },
|
|
1234
|
+
...[...String(r.value)].map(ch => el('span', { text: ch, style: { color: /[A-Za-z]/.test(ch) ? letterColor(ch) : 'var(--sepia)' } }))),
|
|
1235
|
+
));
|
|
1236
|
+
continue;
|
|
1237
|
+
}
|
|
1238
|
+
let html = tintMeterNames(r.value).replace(/~\d+%/g, m => `<span class="pct">${m}</span>`);
|
|
1239
|
+
card.append(el('div', { class: 'synopsis-row' },
|
|
1240
|
+
el('span', { class: 'lab-l', text: r.label }),
|
|
1241
|
+
el('span', { class: 'lab-v', html }),
|
|
1242
|
+
));
|
|
1243
|
+
}
|
|
1244
|
+
card.append(el('p', { class: 'whisper', style: { marginTop: '.7rem' }, text: `read by ${engine === 'clio' ? 'Clio (the legacy parse)' : 'Calliope'} in ${elapsedMs} ms` }));
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
function rhymeRelNode(r) {
|
|
1248
|
+
const lc = `var(--tier-${r.topStress ?? 's'})`;
|
|
1249
|
+
return el('div', { class: 'rhyme-rel' },
|
|
1250
|
+
el('span', { class: 'rw', text: r.fromWord }), ' ',
|
|
1251
|
+
el('span', { class: 'rmeta' }, '[', el('span', { class: 'rletter', text: r.letter, style: { color: lc } }), `|${r.fromLabel}${r.kind !== 'end' ? '|' + r.kind : ''}]`),
|
|
1252
|
+
el('span', { class: 'rmeta', text: ' → ' }),
|
|
1253
|
+
el('span', { class: 'rw', text: r.toWord }), ' ',
|
|
1254
|
+
el('span', { class: 'rmeta' }, '[', el('span', { class: 'rletter', text: r.letter, style: { color: lc } }), `|${r.toLabel}]`),
|
|
1255
|
+
r.type ? el('span', { class: 'rtype', text: ' ' + r.type }) : null,
|
|
1256
|
+
);
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
function renderPhonopoetics() {
|
|
1260
|
+
const p = state.data.phonopoetics;
|
|
1261
|
+
const card = els.phono;
|
|
1262
|
+
card.innerHTML = '';
|
|
1263
|
+
card.append(el('h3', { class: 'rail-title violet' }, 'Phonopoetics'));
|
|
1264
|
+
|
|
1265
|
+
const groups = [];
|
|
1266
|
+
if (p?.endScheme) groups.push(el('div', { class: 'phono-group' },
|
|
1267
|
+
el('div', { class: 'phono-label', text: 'End-rhyme scheme' }),
|
|
1268
|
+
el('div', { class: 'scheme-letters' },
|
|
1269
|
+
...[...p.endScheme].map(ch => el('span', { text: ch, style: { color: /[A-Za-z]/.test(ch) ? letterColor(ch) : 'var(--sepia)' } }))),
|
|
1270
|
+
));
|
|
1271
|
+
const rel = (label, items, title) => {
|
|
1272
|
+
if (!items?.length) return;
|
|
1273
|
+
groups.push(el('div', { class: 'phono-group' },
|
|
1274
|
+
el('div', { class: 'phono-label', text: label, title }),
|
|
1275
|
+
el('div', { class: 'phono-items' }, items.map(rhymeRelNode)),
|
|
1276
|
+
));
|
|
1277
|
+
};
|
|
1278
|
+
rel('End rhymes', p?.end, 'rhymes binding line-ends');
|
|
1279
|
+
rel('Caesural rhymes', p?.caesural, 'internal rhymes landing just before a mid-line pause');
|
|
1280
|
+
rel('Head rhymes', p?.head, 'rhymes at line-openings');
|
|
1281
|
+
if (p?.alliteration?.length) {
|
|
1282
|
+
groups.push(el('div', { class: 'phono-group' },
|
|
1283
|
+
el('div', { class: 'phono-label', text: 'Alliteration' }),
|
|
1284
|
+
el('div', { class: 'phono-items' }, p.alliteration.map(a =>
|
|
1285
|
+
el('div', { class: 'rhyme-rel allit-run' },
|
|
1286
|
+
el('span', { class: 'rw', text: a.words.join(' ') }),
|
|
1287
|
+
el('span', { class: 'rmeta', text: ` (${a.label})` })))),
|
|
1288
|
+
));
|
|
1289
|
+
}
|
|
1290
|
+
if (p?.acrostics?.length) {
|
|
1291
|
+
groups.push(el('div', { class: 'phono-group' },
|
|
1292
|
+
el('div', { class: 'phono-label', text: 'Acrostic' }),
|
|
1293
|
+
el('div', { class: 'phono-items' }, p.acrostics.map(a =>
|
|
1294
|
+
el('div', { class: 'rhyme-rel' },
|
|
1295
|
+
el('span', { class: 'rmeta', text: a.labels.map((l, i) => `[${l}:${a.firsts[i]}]`).join('') }),
|
|
1296
|
+
el('span', { class: 'rmeta', text: ' → ' }),
|
|
1297
|
+
el('span', { class: 'rw', text: a.word, style: { color: 'var(--amber)' } })))),
|
|
1298
|
+
));
|
|
1299
|
+
}
|
|
1300
|
+
if (!groups.length) {
|
|
1301
|
+
card.append(el('p', { class: 'whisper', text: 'No rhyme-work, alliteration runs, or acrostics surfaced in this piece.' }));
|
|
1302
|
+
} else {
|
|
1303
|
+
groups.forEach(g => card.append(g));
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
/* ═══════════════════ Word cards: tooltip & inspector ═══════════════════ */
|
|
1308
|
+
|
|
1309
|
+
function russianWordCardContent(line, w, hoveredG = null, forInspector = false) {
|
|
1310
|
+
const frag = document.createDocumentFragment();
|
|
1311
|
+
frag.append(el(forInspector ? 'h4' : 'div', { class: forInspector ? '' : 'tip-title', text: w.form }));
|
|
1312
|
+
|
|
1313
|
+
const uposGloss = {
|
|
1314
|
+
NOUN: 'noun', VERB: 'verb', ADJ: 'adjective', ADV: 'adverb',
|
|
1315
|
+
PRON: 'pronoun', DET: 'determiner', PROPN: 'proper noun',
|
|
1316
|
+
ADP: 'preposition', CCONJ: 'coordinating conjunction', SCONJ: 'subordinating conjunction',
|
|
1317
|
+
PART: 'particle', INTJ: 'interjection', NUM: 'numeral'
|
|
1318
|
+
};
|
|
1319
|
+
|
|
1320
|
+
frag.append(el('div', { class: forInspector ? 'badge-row' : 'tip-sub' },
|
|
1321
|
+
...(forInspector ? [
|
|
1322
|
+
el('span', { class: `badge ${w.isContent ? 'content' : 'function'}`, text: w.isContent ? 'content word' : 'function word' }),
|
|
1323
|
+
el('span', { class: 'badge pos-badge', text: `${w.upos} · ${uposGloss[w.upos] ?? 'tag'}` }),
|
|
1324
|
+
] : [ `${uposGloss[w.upos] ?? w.upos} · ${w.isContent ? 'content word' : 'function word'}` ]),
|
|
1325
|
+
));
|
|
1326
|
+
|
|
1327
|
+
const addRow = (k, vNode) => frag.append(el('div', { class: forInspector ? 'insp-section' : 'tip-row' },
|
|
1328
|
+
el('span', { class: forInspector ? 'insp-label' : 'tip-k', text: k }),
|
|
1329
|
+
forInspector ? vNode : el('span', { class: 'tip-v' }, vNode),
|
|
1330
|
+
));
|
|
1331
|
+
|
|
1332
|
+
if (w.lemma && w.lemma !== w.form.toLowerCase()) {
|
|
1333
|
+
addRow('lemma', el('span', { class: 'mono', text: w.lemma }));
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
// Dependency bond (from the line's serialized dep edges)
|
|
1337
|
+
{
|
|
1338
|
+
const wi = line.words?.indexOf(w) ?? -1;
|
|
1339
|
+
const dep = wi >= 0 ? (line.deps ?? []).find(d => d.from === wi) : null;
|
|
1340
|
+
if (dep) {
|
|
1341
|
+
const gov = dep.to >= 0 ? line.words[dep.to] : null;
|
|
1342
|
+
addRow('dependency', el('span', {},
|
|
1343
|
+
el('b', { text: dep.rel }),
|
|
1344
|
+
dep.to === -1 ? ' — the clause root' : ' → ',
|
|
1345
|
+
dep.to === -1 ? null : el('span', { class: 'mono', text: gov?.form ?? '?' })
|
|
1346
|
+
));
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
// Features
|
|
1351
|
+
if (w.feats && Object.keys(w.feats).length > 0) {
|
|
1352
|
+
const featStr = Object.entries(w.feats).map(([k, v]) => `${k}=${v}`).join(' ');
|
|
1353
|
+
addRow('morphology', el('span', { class: 'mono', text: featStr }));
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
// Stress info
|
|
1357
|
+
let stressInfo = 'unstressed';
|
|
1358
|
+
if (w.stressPos > 0) {
|
|
1359
|
+
stressInfo = `stressed on vowel ${w.stressPos}`;
|
|
1360
|
+
}
|
|
1361
|
+
if (w.secondaryStress && w.secondaryStress.some(s => s === 2)) {
|
|
1362
|
+
stressInfo += ' (has secondary stress)';
|
|
1363
|
+
}
|
|
1364
|
+
addRow('stress', el('span', { text: stressInfo }));
|
|
1365
|
+
|
|
1366
|
+
return frag;
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
function wordCardContent(line, w, hoveredG = null, forInspector = false) {
|
|
1370
|
+
if (state.data?._lang === 'ru') return russianWordCardContent(line, w, hoveredG, forInspector);
|
|
1371
|
+
|
|
1372
|
+
const frag = document.createDocumentFragment();
|
|
1373
|
+
frag.append(el(forInspector ? 'h4' : 'div', { class: forInspector ? '' : 'tip-title', text: w.text }));
|
|
1374
|
+
|
|
1375
|
+
const dep = depPhrase(w);
|
|
1376
|
+
frag.append(el('div', { class: forInspector ? 'badge-row' : 'tip-sub' },
|
|
1377
|
+
...(forInspector ? [
|
|
1378
|
+
el('span', { class: `badge ${w.isContent ? 'content' : 'function'}`, text: w.isContent ? 'content word' : 'function word' }),
|
|
1379
|
+
el('span', { class: 'badge pos-badge', text: `${w.pos} · ${POS_GLOSS[w.pos] ?? 'tag'}` }),
|
|
1380
|
+
w.flags.person ? el('span', { class: 'badge flag', text: 'person name' }) : null,
|
|
1381
|
+
w.flags.place ? el('span', { class: 'badge flag', text: 'place name' }) : null,
|
|
1382
|
+
w.flags.given ? el('span', { class: 'badge flag', text: 'discourse-given', title: 'repeated from an earlier line — may be prosodically subordinated' }) : null,
|
|
1383
|
+
w.flags.coordGiven ? el('span', { class: 'badge flag', text: 'given in coordination' }) : null,
|
|
1384
|
+
(() => { const st = wordStatus(w); return el('span', { class: 'badge', text: st.label, title: st.hint, style: { color: st.color, borderColor: st.color } }); })(),
|
|
1385
|
+
...rolesFor(w).map(r => el('span', { class: 'badge', text: r.label, title: r.hint, style: { color: r.color, borderColor: r.color } })),
|
|
1386
|
+
] : [ `${POS_GLOSS[w.pos] ?? w.pos} · ${w.isContent ? 'content word' : 'function word'}` ]),
|
|
1387
|
+
));
|
|
1388
|
+
|
|
1389
|
+
const addRow = (k, vNode) => frag.append(el('div', { class: forInspector ? 'insp-section' : 'tip-row' },
|
|
1390
|
+
el('span', { class: forInspector ? 'insp-label' : 'tip-k', text: k }),
|
|
1391
|
+
forInspector ? vNode : el('span', { class: 'tip-v' }, vNode),
|
|
1392
|
+
));
|
|
1393
|
+
|
|
1394
|
+
if (dep) {
|
|
1395
|
+
addRow('grammar', forInspector
|
|
1396
|
+
? el('div', { class: 'insp-text', html: `${esc(dep.text)} <span class="mono">(←${esc(dep.rel)})</span>` })
|
|
1397
|
+
: el('span', { html: `${esc(dep.text)} <span class="mono">←${esc(dep.rel)}</span>` }));
|
|
1398
|
+
}
|
|
1399
|
+
if (forInspector) {
|
|
1400
|
+
const deps = dependentsOf(line, w.i);
|
|
1401
|
+
if (deps.length) {
|
|
1402
|
+
addRow('governs', el('div', { class: 'insp-text', html: deps.map(d => `${esc(d.word)} <span class="mono">←${esc(d.rel)}</span>`).join(' · ') }));
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
if (w.feats) {
|
|
1406
|
+
const chips = el('div', { class: 'feat-chips' },
|
|
1407
|
+
Object.entries(w.feats).map(([k, v]) => {
|
|
1408
|
+
const { k: kk, v: vv } = featPhrase(k, v);
|
|
1409
|
+
return el('span', { class: 'feat-chip', html: `${esc(kk)}: <b>${esc(vv)}</b>` });
|
|
1410
|
+
}));
|
|
1411
|
+
addRow('morphology', chips);
|
|
1412
|
+
}
|
|
1413
|
+
if (w.morph) {
|
|
1414
|
+
addRow('stem split', el('span', { class: forInspector ? 'insp-text' : '', text: [w.morph.prefix && `prefix “${w.morph.prefix}”`, w.morph.suffix && `suffix “${w.morph.suffix}”`].filter(Boolean).join(' · ') + ' (out-of-dictionary word, stressed by rule)' }));
|
|
1415
|
+
}
|
|
1416
|
+
const ps = w.phraseStress || 0;
|
|
1417
|
+
if (ps > 0) {
|
|
1418
|
+
addRow('phrase rank', el('span', { class: forInspector ? 'insp-text' : '', text: ps === 1 ? '1 — the strongest word of the utterance' : `${ps} (1 = strongest)` }));
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
// syllables
|
|
1422
|
+
if (forInspector) {
|
|
1423
|
+
const table = el('table', { class: 'syl-mini' },
|
|
1424
|
+
el('tr', {}, el('th', { text: 'syllable' }), el('th', { text: 'phones' }), el('th', { text: 'weight' }), el('th', { text: 'lexical' }), el('th', { text: 'relative' })),
|
|
1425
|
+
w.syls.map(s => el('tr', {},
|
|
1426
|
+
el('td', { class: 'stx', text: s.text || '·', style: { color: `var(--tier-${s.rel})` } }),
|
|
1427
|
+
el('td', { text: s.phones.replace(/[()]/g, '') }),
|
|
1428
|
+
el('td', { text: s.weight === 'H' ? 'H · heavy' : s.weight === 'L' ? 'L · light' : '—' }),
|
|
1429
|
+
el('td', { text: `${s.lex} · ${LEX_NAMES[Math.min(3, s.lex)]}` }),
|
|
1430
|
+
el('td', { text: `${s.rel} · ${TIERS[s.rel]?.name}`, style: { color: `var(--tier-${s.rel})` } }),
|
|
1431
|
+
)));
|
|
1432
|
+
addRow('syllables', table);
|
|
1433
|
+
if (w.syls.some(s => s.extrametrical)) {
|
|
1434
|
+
addRow('extrametrical', el('span', { class: 'insp-text', text: w.syls.filter(s => s.extrametrical).map(s => `“${s.text}” (${s.extrametrical})`).join(', ') + ' — kept outside the stress count' }));
|
|
1435
|
+
}
|
|
1436
|
+
} else {
|
|
1437
|
+
// role badges: grammatical status + poem-wide sound roles
|
|
1438
|
+
const roleRow = el('div', {});
|
|
1439
|
+
const status = wordStatus(w);
|
|
1440
|
+
roleRow.append(el('span', { class: 'tip-role', text: status.label, title: status.hint, style: { color: status.color, borderColor: status.color } }));
|
|
1441
|
+
for (const role of rolesFor(w)) {
|
|
1442
|
+
roleRow.append(el('span', { class: 'tip-role', text: role.label, title: role.hint, style: { color: role.color, borderColor: role.color } }));
|
|
1443
|
+
}
|
|
1444
|
+
frag.append(roleRow);
|
|
1445
|
+
|
|
1446
|
+
const dependents = dependentsOf(line, w.i);
|
|
1447
|
+
if (dependents.length) {
|
|
1448
|
+
frag.append(el('div', { class: 'tip-row' },
|
|
1449
|
+
el('span', { class: 'tip-k', text: 'governs' }),
|
|
1450
|
+
el('span', { class: 'tip-v', html: dependents.map(d => `${esc(d.word)} <span class="mono">←${esc(d.rel)}</span>`).join(' · ') })));
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
const sylRow = el('div', { class: 'tip-syls' },
|
|
1454
|
+
w.syls.map(s => {
|
|
1455
|
+
const g = w.firstSyl + s.si;
|
|
1456
|
+
return el('span', { class: 'tip-syl', style: hoveredG === g ? { borderColor: `var(--tier-${s.rel})` } : {} },
|
|
1457
|
+
el('span', { class: 't', text: s.text || '·', style: { color: `var(--tier-${s.rel})` } }),
|
|
1458
|
+
el('span', { class: 'p', text: s.phones.replace(/[()]/g, '') }),
|
|
1459
|
+
el('span', { class: 'p', text: `${s.weight ?? ''} ${s.rel}·${TIERS[s.rel]?.name}` }),
|
|
1460
|
+
);
|
|
1461
|
+
}));
|
|
1462
|
+
frag.append(sylRow);
|
|
1463
|
+
|
|
1464
|
+
// fine-grained anatomy of the hovered syllable
|
|
1465
|
+
const hovered = hoveredG != null ? w.syls.find(s => w.firstSyl + s.si === hoveredG) : null;
|
|
1466
|
+
if (hovered?.ph) {
|
|
1467
|
+
const p = hovered.ph;
|
|
1468
|
+
frag.append(el('div', { class: 'tip-sylcard' },
|
|
1469
|
+
el('div', { class: 'tsc-head', text: `this syllable · “${hovered.text}” (${hovered.pos})` }),
|
|
1470
|
+
el('div', { class: 'tsc-row', html: `onset <b>${esc(p.onset)}</b> ${p.onset === '0' ? '(null onset)' : `(${p.onset.length}-consonant ${p.onset.length > 1 ? 'cluster' : 'singleton'})`} · nucleus <b>${esc(p.nucleus)}</b>, ${esc(p.vlen)} vowel` }),
|
|
1471
|
+
el('div', { class: 'tsc-row', html: `rime <b>${esc(p.rime)}</b> · ${p.open ? 'open (no coda)' : `closed by ${p.codaC}-consonant coda`} · weight <b>${hovered.weight === 'H' ? 'H heavy' : hovered.weight === 'L' ? 'L light' : '—'}</b>` }),
|
|
1472
|
+
));
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
// async Nounsing strip fills in below (hover handler)
|
|
1476
|
+
frag.append(el('div', { class: 'tip-nounsing-slot' }));
|
|
1477
|
+
frag.append(el('div', { class: 'tip-hint', text: 'click to pin the full anatomy in the Inspector →' }));
|
|
1478
|
+
}
|
|
1479
|
+
return frag;
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
/* ── Nounsing Pro dossier: deep morpho-phonological word data ── */
|
|
1483
|
+
|
|
1484
|
+
const dossierCache = new Map();
|
|
1485
|
+
async function fetchDossier(word) {
|
|
1486
|
+
const key = word.toLowerCase();
|
|
1487
|
+
if (dossierCache.has(key)) return dossierCache.get(key);
|
|
1488
|
+
const r = await fetch('/api/word?w=' + encodeURIComponent(key));
|
|
1489
|
+
const j = await r.json();
|
|
1490
|
+
if (!r.ok || j.error) throw new Error(j.error || 'dossier failed');
|
|
1491
|
+
dossierCache.set(key, j);
|
|
1492
|
+
return j;
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
const FOOT_GLYPHS = {
|
|
1496
|
+
iambic: '˘ ¯', trochaic: '¯ ˘', spondaic: '¯ ¯', pyrrhic: '˘ ˘',
|
|
1497
|
+
dactylic: '¯ ˘ ˘', anapestic: '˘ ˘ ¯', amphibrachic: '˘ ¯ ˘', bacchic: '˘ ¯ ¯',
|
|
1498
|
+
cretic: '¯ ˘ ¯', antibacchic: '¯ ¯ ˘', monosyllabic: '¯',
|
|
1499
|
+
};
|
|
1500
|
+
const SUFFIX_TYPE_GLOSS = {
|
|
1501
|
+
noshiftOneSyll: 'a one-syllable suffix that never moves the stress',
|
|
1502
|
+
noshiftTwoSyll: 'a two-syllable suffix that never moves the stress',
|
|
1503
|
+
shift: 'a stress-shifting suffix — it drags the accent toward itself',
|
|
1504
|
+
neutral: 'stress-neutral', noSuffix: 'no productive suffix',
|
|
1505
|
+
};
|
|
1506
|
+
|
|
1507
|
+
function dossierRow(k, vNode, title) {
|
|
1508
|
+
return el('div', { class: 'dossier-row', title },
|
|
1509
|
+
el('span', { class: 'dr-k', text: k }),
|
|
1510
|
+
el('span', { class: 'dr-v' }, vNode));
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
function renderDossier(d) {
|
|
1514
|
+
const box = el('div', { class: 'dossier' });
|
|
1515
|
+
box.append(el('div', { class: 'dossier-title', text: 'Nounsing Pro record' }));
|
|
1516
|
+
|
|
1517
|
+
if (!d.phonemics && !d.scansion && !d.lexicon) {
|
|
1518
|
+
box.append(el('p', { class: 'dossier-oov', text: 'This word is beyond the augmented CMU lexicon — its stresses were conjured by rule (the quantity-sensitive English Stress Rule), so no deep record exists for it.' }));
|
|
1519
|
+
return box;
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
// the word's own foot
|
|
1523
|
+
if (d.scansion?.label) {
|
|
1524
|
+
const glyph = FOOT_GLYPHS[d.scansion.label] ?? '';
|
|
1525
|
+
box.append(el('div', { class: 'dossier-foot' },
|
|
1526
|
+
el('span', { class: 'df-label', text: d.scansion.label,
|
|
1527
|
+
style: { color: METER_HUE[d.scansion.label] ?? METER_HUE[d.scansion.label + 'ic'] ?? 'var(--madder)' } }),
|
|
1528
|
+
glyph ? el('span', { class: 'df-pattern', text: glyph }) : null,
|
|
1529
|
+
el('span', { class: 'df-pattern', text: `contour ${d.scansion.contour} · weights ${d.scansion.weightPattern}`,
|
|
1530
|
+
title: 'the word’s own metrical shape: stress contour (1 primary · 2 secondary · 0 none) and heavy/light syllable pattern' }),
|
|
1531
|
+
));
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
const g = el('div', { class: 'dossier-grid' });
|
|
1535
|
+
if (d.lexicon) {
|
|
1536
|
+
g.append(dossierRow('lexicon', `${d.lexicon.nsylls} syllable${d.lexicon.nsylls === 1 ? '' : 's'} · ${zipfGloss(d.lexicon.freq) ?? 'frequency unknown'}`,
|
|
1537
|
+
'from the augmented CMU lexicon: syllable count and Zipf-scale word frequency (1 = vanishingly rare, 7 = “the”)'));
|
|
1538
|
+
}
|
|
1539
|
+
if (d.phonemics) {
|
|
1540
|
+
g.append(dossierRow('syllabified', d.phonemics.syllabification,
|
|
1541
|
+
'the dictionary’s phonemic syllabification (maximal-onset)'));
|
|
1542
|
+
g.append(dossierRow('structure', `${d.phonemics.syllStruct} · ${d.phonemics.vowelLength === 'shortV' ? 'short vowels' : d.phonemics.vowelLength === 'longV' ? 'long vowels' : d.phonemics.vowelLength}`,
|
|
1543
|
+
'C = consonant, L = light/short nucleus; per-syllable shape'));
|
|
1544
|
+
}
|
|
1545
|
+
if (d.onsetParse) {
|
|
1546
|
+
g.append(dossierRow('CV skeleton', `${d.onsetParse.cvStructure}${d.onsetParse.isPenultClosed ? ' · penult closed' : ''}`,
|
|
1547
|
+
'the consonant-vowel skeleton after the Maximal Onset Principle'));
|
|
1548
|
+
}
|
|
1549
|
+
if (d.weights?.pattern) {
|
|
1550
|
+
g.append(dossierRow('weights', d.weights.pattern.join(' '),
|
|
1551
|
+
'H = heavy syllable (attracts stress), L = light'));
|
|
1552
|
+
}
|
|
1553
|
+
if (d.edges) {
|
|
1554
|
+
const bits = [];
|
|
1555
|
+
if (d.edges.finalComplexOnset && d.edges.finalComplexOnset !== 'simple') bits.push(`complex final onset (${d.edges.finalComplexOnset})`);
|
|
1556
|
+
bits.push(`coda “${(d.edges.coda ?? '').trim() || '∅'}” (${d.edges.codaLength} consonant${d.edges.codaLength === 1 ? '' : 's'})`);
|
|
1557
|
+
g.append(dossierRow('edges', bits.join(' · '), 'onset & coda geometry of the word’s final syllable'));
|
|
1558
|
+
}
|
|
1559
|
+
if (d.codaComplexity?.isComplex) {
|
|
1560
|
+
g.append(dossierRow('rare coda', `${d.codaComplexity.complexity} — ${d.codaComplexity.phonemes}`,
|
|
1561
|
+
'a rare complex coda cluster'));
|
|
1562
|
+
}
|
|
1563
|
+
if (d.vowelQualities) {
|
|
1564
|
+
const vq = d.vowelQualities;
|
|
1565
|
+
g.append(dossierRow('vowels', `${vq.diphthongs} diphthong${vq.diphthongs === 1 ? '' : 's'} · ${vq.monophthongs} monophthong${vq.monophthongs === 1 ? '' : 's'}${vq.allMonophthong ? ' — pure monophthong word' : ''}`,
|
|
1566
|
+
'gliding (diphthong) vs steady (monophthong) vowel qualities'));
|
|
1567
|
+
}
|
|
1568
|
+
if (d.morphology) {
|
|
1569
|
+
const m = d.morphology;
|
|
1570
|
+
const bits = [`${m.morphology}`];
|
|
1571
|
+
if (m.prefix && m.prefix !== 'noPrefix') bits.push(`prefix ${m.prefix}`);
|
|
1572
|
+
if (m.suffix && m.suffix !== 'noSuffix') bits.push(SUFFIX_TYPE_GLOSS[m.suffixType] ?? `suffix (${m.suffixType})`);
|
|
1573
|
+
g.append(dossierRow('morphology', bits.join(' · '), 'morpho-phonological build of the word'));
|
|
1574
|
+
}
|
|
1575
|
+
if (d.suffixShift) {
|
|
1576
|
+
g.append(dossierRow('suffix shift', d.suffixShift.shiftLikely
|
|
1577
|
+
? 'a further suffix would likely DRAG the stress rightward'
|
|
1578
|
+
: 'stress stays put under suffixation',
|
|
1579
|
+
'would adding a suffix force a stress shift?'));
|
|
1580
|
+
}
|
|
1581
|
+
if (d.extrametricals?.status && d.extrametricals.status !== 'none') {
|
|
1582
|
+
g.append(dossierRow('extrametrical', `${d.extrametricals.status}${d.extrametricals.isIrregular ? ' (irregular)' : ''}`,
|
|
1583
|
+
'edge material (like plural -s) standing outside the weight count'));
|
|
1584
|
+
}
|
|
1585
|
+
if (d.rhymeProfile) {
|
|
1586
|
+
g.append(dossierRow('rime', el('span', {},
|
|
1587
|
+
el('span', { text: d.rhymeProfile.rhymingPhones + ' ' }),
|
|
1588
|
+
el('span', { text: `(${d.rhymeProfile.weight === 'H' ? 'heavy' : 'light'} rime${d.rhymeProfile.hasExtrametricalS ? ', extrametrical -s' : ''})` })),
|
|
1589
|
+
'the exact phoneme string a perfect rhyme must echo'));
|
|
1590
|
+
}
|
|
1591
|
+
box.append(g);
|
|
1592
|
+
|
|
1593
|
+
if (d.insets && Object.keys(d.insets).length) {
|
|
1594
|
+
const insetBox = el('div', { class: 'insp-section' },
|
|
1595
|
+
el('div', { class: 'insp-label', text: 'feet hiding inside the word' }));
|
|
1596
|
+
const list = el('div', { class: 'inset-feet' });
|
|
1597
|
+
for (const [foot, groups] of Object.entries(d.insets)) {
|
|
1598
|
+
for (const run of groups) {
|
|
1599
|
+
list.append(el('div', { class: 'inset-row' },
|
|
1600
|
+
el('span', { class: 'if-name', text: foot, style: { color: METER_HUE[foot + 'ic'] ?? METER_HUE[foot] ?? 'var(--heliotrope)' } }),
|
|
1601
|
+
el('span', { class: 'if-syls', text: run.map(x => x.syll).join(' ') })));
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
insetBox.append(list);
|
|
1605
|
+
box.append(insetBox);
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
if (d.rhymes?.length) {
|
|
1609
|
+
const rBox = el('div', { class: 'insp-section' },
|
|
1610
|
+
el('div', { class: 'insp-label', text: 'strict rhymes, from the lexicon' }),
|
|
1611
|
+
el('div', { class: 'rhyme-cloud' }, d.rhymes.map(r => el('span', { class: 'rc', text: r }))));
|
|
1612
|
+
box.append(rBox);
|
|
1613
|
+
}
|
|
1614
|
+
return box;
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
function renderInspector() {
|
|
1618
|
+
const body = els.inspectorBody;
|
|
1619
|
+
body.innerHTML = '';
|
|
1620
|
+
$$('.word.pinned', els.codex).forEach(n => n.classList.remove('pinned'));
|
|
1621
|
+
if (!state.pinned) { els.inspectorHint.hidden = false; return; }
|
|
1622
|
+
const { s, l, w } = state.pinned;
|
|
1623
|
+
const line = state.data.stanzas[s]?.lines[l];
|
|
1624
|
+
const word = line?.words?.[w];
|
|
1625
|
+
if (!word) { state.pinned = null; els.inspectorHint.hidden = false; return; }
|
|
1626
|
+
els.inspectorHint.hidden = true;
|
|
1627
|
+
const box = el('div', { class: 'inspector-word' });
|
|
1628
|
+
box.append(wordCardContent(line, word, null, true));
|
|
1629
|
+
|
|
1630
|
+
const dossierSlot = el('div', {});
|
|
1631
|
+
box.append(dossierSlot);
|
|
1632
|
+
if (state.data?._lang !== 'ru') {
|
|
1633
|
+
const wanted = word.norm;
|
|
1634
|
+
fetchDossier(wanted).then(d => {
|
|
1635
|
+
if (state.pinned && state.pinned.s === s && state.pinned.l === l && state.pinned.w === w) {
|
|
1636
|
+
dossierSlot.append(renderDossier(d));
|
|
1637
|
+
}
|
|
1638
|
+
}).catch(() => {
|
|
1639
|
+
dossierSlot.append(el('p', { class: 'dossier-oov', text: 'The Nounsing record could not be fetched.' }));
|
|
1640
|
+
});
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
box.append(el('button', { class: 'ghost-btn unpin-btn', text: 'unpin', onclick: () => { state.pinned = null; renderInspector(); } }));
|
|
1644
|
+
body.append(box);
|
|
1645
|
+
const wordEl = $(`.word[data-s="${s}"][data-l="${l}"][data-w="${w}"]`, els.codex);
|
|
1646
|
+
if (wordEl) wordEl.classList.add('pinned');
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
/* ── tooltip behaviour ── */
|
|
1650
|
+
|
|
1651
|
+
let tipTarget = null;
|
|
1652
|
+
els.codex ?. addEventListener('mouseover', (e) => {
|
|
1653
|
+
const wordEl = e.target.closest('.word');
|
|
1654
|
+
if (!wordEl || !els.codex.contains(wordEl)) return;
|
|
1655
|
+
if (tipTarget === wordEl) return;
|
|
1656
|
+
tipTarget = wordEl;
|
|
1657
|
+
const s = +wordEl.dataset.s, l = +wordEl.dataset.l, w = +wordEl.dataset.w;
|
|
1658
|
+
const line = state.data?.stanzas[s]?.lines[l];
|
|
1659
|
+
const word = line?.words?.[w];
|
|
1660
|
+
if (!word) return;
|
|
1661
|
+
const g = e.target.classList?.contains('chunk') ? +e.target.dataset.g : null;
|
|
1662
|
+
els.tooltip.innerHTML = '';
|
|
1663
|
+
els.tooltip.append(wordCardContent(line, word, g, false));
|
|
1664
|
+
els.tooltip.hidden = false;
|
|
1665
|
+
positionTip(e);
|
|
1666
|
+
|
|
1667
|
+
// async: the compact Nounsing Pro strip (cached after first hover)
|
|
1668
|
+
if (state.lang !== 'ru') {
|
|
1669
|
+
const strip = els.tooltip.querySelector('.tip-nounsing-slot');
|
|
1670
|
+
if (strip) {
|
|
1671
|
+
fetchDossier(word.norm).then(d => {
|
|
1672
|
+
if (tipTarget !== wordEl || !strip.isConnected) return;
|
|
1673
|
+
const lines = [];
|
|
1674
|
+
if (d.scansion?.label) lines.push(`as a foot: <b>${esc(d.scansion.label)}</b> <span class="mono">${esc(FOOT_GLYPHS[d.scansion.label] ?? '')}</span>`);
|
|
1675
|
+
if (d.phonemics) lines.push(`syllabified <span class="mono">${esc(d.phonemics.syllabification)}</span>`);
|
|
1676
|
+
if (d.weights?.pattern) lines.push(`weights <b>${esc(d.weights.pattern.join(' '))}</b>${d.vowelQualities ? ` · ${d.vowelQualities.diphthongs} diphthong${d.vowelQualities.diphthongs === 1 ? '' : 's'}` : ''}`);
|
|
1677
|
+
const z = d.lexicon ? zipfGloss(d.lexicon.freq) : null;
|
|
1678
|
+
if (z) lines.push(`frequency: <b>${esc(z)}</b>`);
|
|
1679
|
+
if (d.morphology) lines.push(`morphology: <b>${esc(d.morphology.morphology)}</b>${d.suffixShift ? ` · suffix stress-shift ${d.suffixShift.shiftLikely ? '<b>likely</b>' : 'unlikely'}` : ''}`);
|
|
1680
|
+
if (d.rhymeProfile) lines.push(`rime to echo: <span class="mono">${esc(d.rhymeProfile.rhymingPhones)}</span> (${d.rhymeProfile.weight === 'H' ? 'heavy' : 'light'})`);
|
|
1681
|
+
if (d.rhymes?.length) lines.push(`lexicon rhymes: <i>${esc(d.rhymes.slice(0, 6).join(', '))}${d.rhymes.length > 6 ? '…' : ''}</i>`);
|
|
1682
|
+
if (!lines.length) return;
|
|
1683
|
+
const box = el('div', { class: 'tip-nounsing' }, lines.map(h => el('div', { class: 'tn-line', html: h })));
|
|
1684
|
+
strip.append(box);
|
|
1685
|
+
positionTip(e);
|
|
1686
|
+
}).catch(() => { /* hover strip is best-effort */ });
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
});
|
|
1690
|
+
els.codex ?. addEventListener('mousemove', (e) => { if (!els.tooltip.hidden) positionTip(e); });
|
|
1691
|
+
els.codex ?. addEventListener('mouseout', (e) => {
|
|
1692
|
+
if (tipTarget && !tipTarget.contains(e.relatedTarget)) { tipTarget = null; els.tooltip.hidden = true; }
|
|
1693
|
+
});
|
|
1694
|
+
els.codex ?. addEventListener('click', (e) => {
|
|
1695
|
+
const wordEl = e.target.closest('.word');
|
|
1696
|
+
if (!wordEl) return;
|
|
1697
|
+
const s = +wordEl.dataset.s, l = +wordEl.dataset.l, w = +wordEl.dataset.w;
|
|
1698
|
+
if (state.pinned && state.pinned.s === s && state.pinned.l === l && state.pinned.w === w) state.pinned = null;
|
|
1699
|
+
else state.pinned = { s, l, w };
|
|
1700
|
+
renderInspector();
|
|
1701
|
+
});
|
|
1702
|
+
|
|
1703
|
+
function positionTip(e) {
|
|
1704
|
+
const tip = els.tooltip;
|
|
1705
|
+
const pad = 14;
|
|
1706
|
+
const rect = tip.getBoundingClientRect();
|
|
1707
|
+
let x = e.clientX + pad, y = e.clientY + pad;
|
|
1708
|
+
if (x + rect.width > window.innerWidth - 8) x = e.clientX - rect.width - pad;
|
|
1709
|
+
if (y + rect.height > window.innerHeight - 8) y = e.clientY - rect.height - pad;
|
|
1710
|
+
tip.style.left = Math.max(6, x) + 'px';
|
|
1711
|
+
tip.style.top = Math.max(6, y) + 'px';
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
/* ═══════════════════ Legend ═══════════════════ */
|
|
1715
|
+
|
|
1716
|
+
function buildLegend() {
|
|
1717
|
+
const b = els.legendBody;
|
|
1718
|
+
b.innerHTML = '';
|
|
1719
|
+
|
|
1720
|
+
b.append(el('h3', { text: 'How to read this page' }));
|
|
1721
|
+
b.append(el('p', { html: 'Calliope works out how the poem would be <em>said aloud</em> — dictionary stress, the way speech strands and braids the words, where the voice peaks — and only then listens for the regular beat that saying settles into. Every syllable in the poem is tinted by the stress it carries. Hover over any word for detailed info. Click a word to latch its profile to the side board. Click a line’s meter tag for its detailed scan.' }));
|
|
1722
|
+
|
|
1723
|
+
b.append(el('h3', { text: 'The five stress tiers' }));
|
|
1724
|
+
const tierGrid = el('div', { class: 'legend-grid' });
|
|
1725
|
+
for (const [k, t] of Object.entries(TIERS)) {
|
|
1726
|
+
tierGrid.append(el('div', { class: 'legend-item' },
|
|
1727
|
+
el('span', { class: 'tier-dot', style: { background: t.color } }),
|
|
1728
|
+
el('span', { class: 'li-key', text: k, style: { color: t.color } }),
|
|
1729
|
+
el('span', {}, el('span', { class: 'li-name', text: t.name + ' — ' }), el('span', { class: 'li-eg', text: t.hint }))));
|
|
1730
|
+
}
|
|
1731
|
+
b.append(tierGrid);
|
|
1732
|
+
|
|
1733
|
+
b.append(el('h3', { text: 'Marks & brackets' }));
|
|
1734
|
+
const marks = [
|
|
1735
|
+
['|', 'foot boundary — the meter’s repeating unit'],
|
|
1736
|
+
['‖', 'strong caesura — a real spoken pause (clause end, heavy punctuation)'],
|
|
1737
|
+
['¦', 'lighter phrase break, inferred for punctuation-free lines'],
|
|
1738
|
+
['·', 'silent beat — a rest keeping two clashing stresses apart'],
|
|
1739
|
+
['[ ]', 'κ · clitic group — one content word plus its little function-word satellites'],
|
|
1740
|
+
['{ }', 'ϕ · phonological phrase — clitic groups joined by syntax (tinted cold→warm by boundary strength)'],
|
|
1741
|
+
['< >', 'ι · intonational unit — a whole spoken contour, bounded by major punctuation'],
|
|
1742
|
+
];
|
|
1743
|
+
const markGrid = el('div', { class: 'legend-grid' });
|
|
1744
|
+
marks.forEach(([k, name]) => markGrid.append(el('div', { class: 'legend-item' },
|
|
1745
|
+
el('span', { class: 'li-key', text: k, style: { color: 'var(--violet-hi)' } }),
|
|
1746
|
+
el('span', { class: 'li-name', text: name }))));
|
|
1747
|
+
b.append(markGrid);
|
|
1748
|
+
|
|
1749
|
+
b.append(el('h3', { text: 'The seven meters' }));
|
|
1750
|
+
b.append(el('p', { text: '¯ marks a beat, ˘ an offbeat. A meter’s name plus its foot count names the line: five iambs make iambic pentameter.' }));
|
|
1751
|
+
const meterGrid = el('div', { class: 'legend-grid' });
|
|
1752
|
+
for (const [name, def] of Object.entries(METER_DEF)) {
|
|
1753
|
+
meterGrid.append(el('div', { class: 'legend-item' },
|
|
1754
|
+
el('span', { class: 'tier-dot', style: { background: METER_HUE[name] } }),
|
|
1755
|
+
el('span', { class: 'li-key', text: def.feet, style: { color: METER_HUE[name] } }),
|
|
1756
|
+
el('span', {}, el('span', { class: 'li-name', text: name + ' — ' + def.hint + ' ' }), el('span', { class: 'li-eg', text: `(${def.eg})` }))));
|
|
1757
|
+
}
|
|
1758
|
+
b.append(meterGrid);
|
|
1759
|
+
|
|
1760
|
+
b.append(el('h3', { text: 'Beyond classical meters' }));
|
|
1761
|
+
b.append(el('p', { html: 'When a stanza keeps a steady <em>count of strong beats</em> while its syllable count wanders, it is read as <b>accentual verse</b> (♪): a <b>dolnik</b> lets 1–2 weak syllables fall between beats, a <b>taktovik</b> 1–3, and free accentual verse constrains only the beat count. This is the native rhythm of ballads, folk verse, and much modern poetry.' }));
|
|
1762
|
+
|
|
1763
|
+
b.append(el('h3', { text: 'Rhyme types' }));
|
|
1764
|
+
const rhymeGrid = el('div', { class: 'legend-grid' });
|
|
1765
|
+
for (const [name, hint] of Object.entries(RHYME_TYPES)) {
|
|
1766
|
+
rhymeGrid.append(el('div', { class: 'legend-item' },
|
|
1767
|
+
el('span', { class: 'li-key', text: name, style: { color: 'var(--green)' } }),
|
|
1768
|
+
el('span', { class: 'li-eg', text: hint })));
|
|
1769
|
+
}
|
|
1770
|
+
b.append(rhymeGrid);
|
|
1771
|
+
|
|
1772
|
+
b.append(el('h3', { text: 'Part-of-speech tags' }));
|
|
1773
|
+
const posGrid = el('div', { class: 'legend-grid' });
|
|
1774
|
+
for (const [tag, name] of Object.entries(POS_GLOSS)) {
|
|
1775
|
+
posGrid.append(el('div', { class: 'legend-item' },
|
|
1776
|
+
el('span', { class: 'li-key', text: tag, style: { color: 'var(--met-iambic)' } }),
|
|
1777
|
+
el('span', {}, el('span', { class: 'li-name', text: name + ' ' }), el('span', { class: 'li-eg', text: POS_EG[tag] ? `· ${POS_EG[tag]}` : '' }))));
|
|
1778
|
+
}
|
|
1779
|
+
b.append(posGrid);
|
|
1780
|
+
|
|
1781
|
+
b.append(el('h3', { text: 'Grammatical dependencies' }));
|
|
1782
|
+
b.append(el('p', { text: 'Each word hangs from a governor: “←amod← Mariner” reads “an adjective modifying Mariner”. The root is the word everything else ultimately hangs from.' }));
|
|
1783
|
+
const depGrid = el('div', { class: 'legend-grid' });
|
|
1784
|
+
for (const [rel, name] of Object.entries(DEP_GLOSS)) {
|
|
1785
|
+
depGrid.append(el('div', { class: 'legend-item' },
|
|
1786
|
+
el('span', { class: 'li-key', text: rel, style: { color: 'var(--violet-hi)' } }),
|
|
1787
|
+
el('span', { class: 'li-name', text: name })));
|
|
1788
|
+
}
|
|
1789
|
+
b.append(depGrid);
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
/* ═══════════════════ Russian rendering ═══════════════════ */
|
|
1793
|
+
|
|
1794
|
+
function renderRussianAll() {
|
|
1795
|
+
renderRussianCodex();
|
|
1796
|
+
renderRussianSynopsis();
|
|
1797
|
+
renderInspector();
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
function ruMeterColor(meter) { return RU_METER_HUE[meter] ?? RU_METER_HUE.free; }
|
|
1801
|
+
|
|
1802
|
+
function renderRussianCodex() {
|
|
1803
|
+
const data = state.data;
|
|
1804
|
+
els.codex.innerHTML = '';
|
|
1805
|
+
els.codex.className = 'codex panel ' + tintClass();
|
|
1806
|
+
const multi = data.stanzas.length > 1;
|
|
1807
|
+
let poemLineNo = 0;
|
|
1808
|
+
|
|
1809
|
+
data.stanzas.forEach((st, sIdx) => {
|
|
1810
|
+
const stanza = el('div', { class: 'stanza' });
|
|
1811
|
+
if (multi) {
|
|
1812
|
+
stanza.append(el('div', { class: 'stanza-head' },
|
|
1813
|
+
el('span', { class: 'stanza-no', text: `Stanza ${sIdx + 1}` })));
|
|
1814
|
+
}
|
|
1815
|
+
st.lines.forEach((line, lIdx) => {
|
|
1816
|
+
poemLineNo++;
|
|
1817
|
+
stanza.append(renderRussianLine(line, sIdx, lIdx, poemLineNo));
|
|
1818
|
+
});
|
|
1819
|
+
els.codex.append(stanza);
|
|
1820
|
+
});
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
function renderRussianLine(line, sIdx, lIdx, no) {
|
|
1824
|
+
const row = el('div', { class: 'vline', 'data-s': sIdx, 'data-l': lIdx });
|
|
1825
|
+
const main = el('div', { class: 'vline-main' });
|
|
1826
|
+
main.append(el('span', { class: 'vline-no', text: String(no) }));
|
|
1827
|
+
|
|
1828
|
+
// Render the line text, reconstructed from words to get clickable spans
|
|
1829
|
+
const textSpan = el('span', { class: 'vline-text' });
|
|
1830
|
+
if (line.words && line.words.length > 0) {
|
|
1831
|
+
const rawLine = line.raw;
|
|
1832
|
+
const words = line.words;
|
|
1833
|
+
let wi = 0;
|
|
1834
|
+
const tokenRe = /[А-Яа-яЁё]+/g;
|
|
1835
|
+
let m;
|
|
1836
|
+
let cursor = 0;
|
|
1837
|
+
while ((m = tokenRe.exec(rawLine)) !== null) {
|
|
1838
|
+
if (cursor < m.index) {
|
|
1839
|
+
textSpan.append(el('span', { class: 'gap', text: rawLine.slice(cursor, m.index) }));
|
|
1840
|
+
}
|
|
1841
|
+
cursor = m.index + m[0].length;
|
|
1842
|
+
const tokenLower = m[0].toLowerCase();
|
|
1843
|
+
let matchedWord = null;
|
|
1844
|
+
for (let k = wi; k < Math.min(words.length, wi + 3); k++) {
|
|
1845
|
+
if (words[k].form.toLowerCase().replace(/ё/g, 'е') === tokenLower.replace(/ё/g, 'е')) { matchedWord = words[k]; wi = k + 1; break; }
|
|
1846
|
+
}
|
|
1847
|
+
if (matchedWord) {
|
|
1848
|
+
const wordSpan = el('span', {
|
|
1849
|
+
class: `word ${matchedWord.isContent ? 'is-content' : 'is-function'}`,
|
|
1850
|
+
'data-s': sIdx, 'data-l': lIdx, 'data-w': words.indexOf(matchedWord),
|
|
1851
|
+
});
|
|
1852
|
+
// Render syllables with stress tinting
|
|
1853
|
+
for (let si = 0; si < matchedWord.syllables.length; si++) {
|
|
1854
|
+
const syl = matchedWord.syllables[si];
|
|
1855
|
+
const tier = syl.stressed ? 's' : syl.secondaryStressed ? 'm' : 'w';
|
|
1856
|
+
wordSpan.append(el('span', {
|
|
1857
|
+
class: `chunk t-${tier} l-${syl.stressed ? 2 : 0}`,
|
|
1858
|
+
text: syl.text,
|
|
1859
|
+
title: `vowel: ${syl.vowel}, stressed: ${syl.stressed ? 'yes' : 'no'}`,
|
|
1860
|
+
}));
|
|
1861
|
+
if (si < matchedWord.syllables.length - 1 && state.showFeet) {
|
|
1862
|
+
wordSpan.append(el('span', { class: 'footmark', text: '·' }));
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
textSpan.append(wordSpan);
|
|
1866
|
+
} else {
|
|
1867
|
+
textSpan.append(el('span', { class: 'gap', text: m[0] }));
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
if (cursor < rawLine.length) {
|
|
1871
|
+
textSpan.append(el('span', { class: 'gap', text: rawLine.slice(cursor) }));
|
|
1872
|
+
}
|
|
1873
|
+
} else {
|
|
1874
|
+
textSpan.append(el('span', { class: 'gap', text: line.raw }));
|
|
1875
|
+
}
|
|
1876
|
+
main.append(textSpan);
|
|
1877
|
+
|
|
1878
|
+
// Meter chip
|
|
1879
|
+
const meter = state.data.meter;
|
|
1880
|
+
const mColor = ruMeterColor(meter.meter);
|
|
1881
|
+
const mDef = RU_METER_DEF[meter.meter];
|
|
1882
|
+
const meterChip = el('span', { class: 'meter-chip' },
|
|
1883
|
+
el('span', { class: 'meter-dot', style: { background: mColor, boxShadow: `0 0 8px ${mColor}66` } }),
|
|
1884
|
+
el('span', { style: { color: mColor } }, mDef?.ru || meter.meterRu || meter.meter),
|
|
1885
|
+
meter.footCount > 0 ? el('span', { class: 'fit-pct', text: `×${meter.footCount}` }) : null,
|
|
1886
|
+
el('span', { class: 'fit-pct', text: `${Math.round(meter.score * 100)}%` }),
|
|
1887
|
+
);
|
|
1888
|
+
meterChip.title = mDef ? `${mDef.ru} — ${mDef.en} (${mDef.hint})` : meter.meter;
|
|
1889
|
+
|
|
1890
|
+
// Rhyme chip (rhymes are indexed by poem-global line number)
|
|
1891
|
+
const rhymeEntry = state.data.rhymes?.[countLinesBeforeStanza(sIdx) + lIdx];
|
|
1892
|
+
const rhymeCluster = el('span', { class: 'rhyme-cluster' });
|
|
1893
|
+
if (rhymeEntry && rhymeEntry.letter && rhymeEntry.letter !== '-') {
|
|
1894
|
+
rhymeCluster.append(el('span', {
|
|
1895
|
+
class: 'rhyme-chip', text: rhymeEntry.letter,
|
|
1896
|
+
style: { color: letterColor(rhymeEntry.letter), borderColor: letterColor(rhymeEntry.letter) + '55' },
|
|
1897
|
+
title: `end-word: ${rhymeEntry.endWord}${rhymeEntry.rhymeType ? ' — ' + rhymeEntry.rhymeType + ' rhyme' : ''}${rhymeEntry.matchedLine != null ? ', bound to line ' + (rhymeEntry.matchedLine + 1) : ''}`,
|
|
1898
|
+
}));
|
|
1899
|
+
} else if (rhymeEntry) {
|
|
1900
|
+
rhymeCluster.append(el('span', { class: 'rhyme-chip unrhymed', text: '·', title: `${rhymeEntry.endWord} — unrhymed` }));
|
|
1901
|
+
}
|
|
1902
|
+
main.append(el('span', { class: 'vline-lead', 'aria-hidden': 'true' }));
|
|
1903
|
+
const chips = el('span', { class: 'vline-chips', title: 'open this line’s detailed scan' });
|
|
1904
|
+
chips.append(meterChip, rhymeCluster);
|
|
1905
|
+
chips.addEventListener('click', (e) => { e.stopPropagation(); toggleRussianLab(sIdx, lIdx); });
|
|
1906
|
+
main.append(chips);
|
|
1907
|
+
|
|
1908
|
+
row.append(main);
|
|
1909
|
+
return row;
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
function countLinesBeforeStanza(sIdx) {
|
|
1913
|
+
let n = 0;
|
|
1914
|
+
for (let i = 0; i < sIdx; i++) n += state.data.stanzas[i].lines.length;
|
|
1915
|
+
return n;
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
function renderRussianSynopsis() {
|
|
1919
|
+
const data = state.data;
|
|
1920
|
+
const card = els.synopsis;
|
|
1921
|
+
card.innerHTML = '';
|
|
1922
|
+
card.append(el('h3', { class: 'rail-title' }, 'Poem Synopsis'));
|
|
1923
|
+
|
|
1924
|
+
// Meter row
|
|
1925
|
+
const m = data.meter;
|
|
1926
|
+
const mDef = RU_METER_DEF[m.meter];
|
|
1927
|
+
const mColor = ruMeterColor(m.meter);
|
|
1928
|
+
card.append(el('div', { class: 'synopsis-row' },
|
|
1929
|
+
el('span', { class: 'lab-l', text: 'Meter' }),
|
|
1930
|
+
el('span', { class: 'lab-v', html:
|
|
1931
|
+
`<span style="color:${mColor}">${mDef?.ru || m.meterRu}</span>` +
|
|
1932
|
+
` <span class="whisper">(${mDef?.en || m.meter})</span>` +
|
|
1933
|
+
` <span class="pct">~${Math.round(m.score * 100)}%</span>` }),
|
|
1934
|
+
));
|
|
1935
|
+
|
|
1936
|
+
// Rhyme scheme
|
|
1937
|
+
if (data.rhymeScheme) {
|
|
1938
|
+
card.append(el('div', { class: 'synopsis-row' },
|
|
1939
|
+
el('span', { class: 'lab-l', text: 'Rhyme scheme' }),
|
|
1940
|
+
el('span', { class: 'lab-v scheme-letters' },
|
|
1941
|
+
...[...data.rhymeScheme].map(ch =>
|
|
1942
|
+
el('span', { text: ch, style: { color: /[A-Za-z]/.test(ch) ? letterColor(ch) : 'var(--sepia)' } }))),
|
|
1943
|
+
));
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
// Technicality score
|
|
1947
|
+
card.append(el('div', { class: 'synopsis-row' },
|
|
1948
|
+
el('span', { class: 'lab-l', text: 'Technicality' }),
|
|
1949
|
+
el('span', { class: 'lab-v', text: data.score.toFixed(3) + (data.score > 0.1 ? ' — syllabo-tonic verse' : ' — probable prose/non-metrical') }),
|
|
1950
|
+
));
|
|
1951
|
+
|
|
1952
|
+
// Scansion map (all lines)
|
|
1953
|
+
if (data.stanzas && data.stanzas.length > 0) {
|
|
1954
|
+
const mapBox = el('div', { class: 'synopsis-row', style: { flexDirection: 'column', alignItems: 'flex-start', gap: '0.5rem' } });
|
|
1955
|
+
mapBox.append(el('span', { class: 'lab-l', text: 'Scansion map' }));
|
|
1956
|
+
|
|
1957
|
+
const linesBox = el('div', { style: { display: 'flex', flexDirection: 'column', gap: '2px' } });
|
|
1958
|
+
data.stanzas.forEach((st, sIdx) => {
|
|
1959
|
+
if (sIdx > 0) linesBox.append(el('div', { style: { height: '8px' } })); // stanza break
|
|
1960
|
+
st.lines.forEach(line => {
|
|
1961
|
+
if (line.stressPattern) {
|
|
1962
|
+
linesBox.append(el('span', {
|
|
1963
|
+
class: 'lab-v mono-inline',
|
|
1964
|
+
style: { whiteSpace: 'pre', fontSize: '14px', lineHeight: '1' },
|
|
1965
|
+
text: line.stressPattern.replace(/S/g, '¯').replace(/U/g, '˘')
|
|
1966
|
+
}));
|
|
1967
|
+
}
|
|
1968
|
+
});
|
|
1969
|
+
});
|
|
1970
|
+
mapBox.append(linesBox);
|
|
1971
|
+
card.append(mapBox);
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
card.append(el('p', { class: 'whisper', style: { marginTop: '.7rem' },
|
|
1975
|
+
text: `read by Russian scansion (RPST port) in ${data.elapsedMs ?? '—'} ms` }));
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
function renderRussianInspector() {
|
|
1979
|
+
const card = els.inspectorBody;
|
|
1980
|
+
card.innerHTML = '';
|
|
1981
|
+
card.append(el('p', { class: 'whisper', text: 'Hover or click a stressed word to see its stress profile. Russian scansion data: syllable count, stress position, secondary stress, POS from UDPipe.' }));
|
|
1982
|
+
|
|
1983
|
+
// Find all content words with stress for inspection
|
|
1984
|
+
const allWords = [];
|
|
1985
|
+
for (const st of state.data.stanzas) {
|
|
1986
|
+
for (const line of st.lines) {
|
|
1987
|
+
for (const w of line.words) {
|
|
1988
|
+
if (w.stressPos > 0) allWords.push({ w, line: line.raw });
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
// Show a summary table of the first ~12 stressed words
|
|
1994
|
+
const table = el('div', { class: 'insp-table' });
|
|
1995
|
+
for (const item of allWords.slice(0, 12)) {
|
|
1996
|
+
const w = item.w;
|
|
1997
|
+
const accentMarked = w.form;
|
|
1998
|
+
// Mark the stressed vowel
|
|
1999
|
+
let rendered = '';
|
|
2000
|
+
let vc = 0;
|
|
2001
|
+
for (const c of w.form) {
|
|
2002
|
+
rendered += c;
|
|
2003
|
+
if ('аеёиоуыэюя'.includes(c.toLowerCase())) {
|
|
2004
|
+
vc++;
|
|
2005
|
+
if (vc === w.stressPos) rendered += '\u0301';
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
table.append(el('div', { class: 'insp-row', style: { display: 'flex', gap: '.5rem', padding: '.2rem 0', borderBottom: '1px solid var(--border)' } },
|
|
2009
|
+
el('span', { class: 'mono-inline', text: rendered, style: { color: 'var(--gilt)', minWidth: '6rem' } }),
|
|
2010
|
+
el('span', { class: 'whisper', text: w.upos }),
|
|
2011
|
+
el('span', { class: 'whisper', text: `${w.syllables.length} syl` }),
|
|
2012
|
+
w.secondaryStress ? el('span', { class: 'whisper', text: '· secondary stress', style: { color: 'var(--teal)' } }) : null,
|
|
2013
|
+
));
|
|
2014
|
+
}
|
|
2015
|
+
if (allWords.length > 12) {
|
|
2016
|
+
table.append(el('p', { class: 'whisper', text: `… and ${allWords.length - 12} more` }));
|
|
2017
|
+
}
|
|
2018
|
+
card.append(table);
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
/* ═══════════════════ Controls ═══════════════════ */
|
|
2022
|
+
|
|
2023
|
+
els.analyze.addEventListener('click', analyze);
|
|
2024
|
+
els.input.addEventListener('keydown', (e) => {
|
|
2025
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') analyze();
|
|
2026
|
+
});
|
|
2027
|
+
els.reopen.addEventListener('click', () => {
|
|
2028
|
+
els.scriptorium.hidden = false;
|
|
2029
|
+
els.scriptorium.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
2030
|
+
els.input.focus();
|
|
2031
|
+
});
|
|
2032
|
+
|
|
2033
|
+
SAMPLES.forEach((s, i) => els.sampleSelect.append(el('option', { value: String(i), text: s.name })));
|
|
2034
|
+
els.sampleSelect.addEventListener('change', () => {
|
|
2035
|
+
const i = els.sampleSelect.value;
|
|
2036
|
+
if (i === '') return;
|
|
2037
|
+
const sample = SAMPLES[+i];
|
|
2038
|
+
els.input.value = sample.text;
|
|
2039
|
+
if (sample.lang) {
|
|
2040
|
+
state.lang = sample.lang;
|
|
2041
|
+
els.langSelect.value = sample.lang;
|
|
2042
|
+
updatePlaceholder();
|
|
2043
|
+
}
|
|
2044
|
+
});
|
|
2045
|
+
|
|
2046
|
+
els.langSelect.addEventListener('change', () => {
|
|
2047
|
+
state.lang = els.langSelect.value;
|
|
2048
|
+
updatePlaceholder();
|
|
2049
|
+
if (state.data && els.input.value.trim()) analyze();
|
|
2050
|
+
});
|
|
2051
|
+
|
|
2052
|
+
function updatePlaceholder() {
|
|
2053
|
+
els.input.placeholder = state.lang === 'ru'
|
|
2054
|
+
? 'Вставьте стихотворение (пустые строки разделяют строфы)…'
|
|
2055
|
+
: 'Shall I compare thee to a summer\'s day?\nThou art more lovely and more temperate…';
|
|
2056
|
+
}
|
|
2057
|
+
updatePlaceholder();
|
|
2058
|
+
|
|
2059
|
+
els.tintSelect.addEventListener('change', () => {
|
|
2060
|
+
state.tint = els.tintSelect.value;
|
|
2061
|
+
if (state.data) els.codex.className = 'codex panel ' + tintClass();
|
|
2062
|
+
});
|
|
2063
|
+
els.feetToggle.addEventListener('change', () => {
|
|
2064
|
+
state.showFeet = els.feetToggle.checked;
|
|
2065
|
+
if (state.data) { renderCodex(); renderInspector(); }
|
|
2066
|
+
});
|
|
2067
|
+
|
|
2068
|
+
/* ── settings popover (engine choice lives here — Clio is the understudy) ── */
|
|
2069
|
+
const settingsBtn = $('#btn-settings');
|
|
2070
|
+
const settingsPop = $('#settings-pop');
|
|
2071
|
+
settingsBtn.addEventListener('click', (e) => {
|
|
2072
|
+
e.stopPropagation();
|
|
2073
|
+
settingsPop.hidden = !settingsPop.hidden;
|
|
2074
|
+
settingsBtn.setAttribute('aria-expanded', String(!settingsPop.hidden));
|
|
2075
|
+
});
|
|
2076
|
+
document.addEventListener('click', (e) => {
|
|
2077
|
+
if (!settingsPop.hidden && !settingsPop.contains(e.target) && e.target !== settingsBtn) settingsPop.hidden = true;
|
|
2078
|
+
});
|
|
2079
|
+
$$('input[name="display"]', settingsPop).forEach(radio => {
|
|
2080
|
+
radio.checked = radio.value === state.theme;
|
|
2081
|
+
radio.addEventListener('change', () => {
|
|
2082
|
+
if (!radio.checked || radio.value === state.theme) return;
|
|
2083
|
+
state.theme = radio.value;
|
|
2084
|
+
localStorage.setItem('calliope-theme', state.theme);
|
|
2085
|
+
applyTheme();
|
|
2086
|
+
if (state.data) renderAll();
|
|
2087
|
+
});
|
|
2088
|
+
});
|
|
2089
|
+
$$('input[name="engine"]', settingsPop).forEach(radio => radio.addEventListener('change', () => {
|
|
2090
|
+
if (!radio.checked || radio.value === state.engine) return;
|
|
2091
|
+
state.engine = radio.value;
|
|
2092
|
+
$('#engine-note-pop').textContent = radio.value === 'clio'
|
|
2093
|
+
? 'Clio will read the next scan — expect a rawer, occasionally contrarian parse.'
|
|
2094
|
+
: 'Calliope will read the next scan.';
|
|
2095
|
+
if (state.data && els.input.value.trim()) analyze();
|
|
2096
|
+
}));
|
|
2097
|
+
|
|
2098
|
+
/* ── the cycling sigil: thirteen figures, each with its own motion ── */
|
|
2099
|
+
const SIGILS = [
|
|
2100
|
+
{ f: 'Logo_Var_2_Tunnel_SVG.svg', a: 'sg-vortex' },
|
|
2101
|
+
{ f: 'Logo_Var_3_Triskelion_SVG.svg', a: 'sg-spin-slow' },
|
|
2102
|
+
{ f: 'Logo_Var_6_Triskelion2_SVG.svg', a: 'sg-spin-rev' },
|
|
2103
|
+
{ f: 'Logo_Var_10_Celtic_Knot_SVG.svg', a: 'sg-breathe' },
|
|
2104
|
+
{ f: 'Logo_Var_13_Crescents_SVG.svg', a: 'sg-spin-slow' },
|
|
2105
|
+
{ f: 'Logo_Var_11_Graph_SVG.svg', a: 'sg-sway', disc: true },
|
|
2106
|
+
{ f: 'Logo_Var_8_Radial_Labyrinth_SVG.svg', a: 'sg-spin' },
|
|
2107
|
+
{ f: 'Logo_Var_9_Radial_Labyrinth_2_SVG.svg', a: 'sg-spin-rev' },
|
|
2108
|
+
{ f: 'Logo_Var_5_Penrose_Pentagram_SVG.svg', a: 'sg-breathe' },
|
|
2109
|
+
{ f: 'Logo_Var_7_SixfoldSpirals_SVG.svg', a: 'sg-spin' },
|
|
2110
|
+
{ f: 'Logo_Var_1_Spiral_SVG.svg', a: 'sg-spin-rev', disc: true },
|
|
2111
|
+
{ f: 'Logo_Var_12_Limacon_Knot_SVG.svg', a: 'sg-sway', disc: true },
|
|
2112
|
+
{ f: 'Logo_Var_4_Quinquetra_PNG.png', a: 'sg-breathe' },
|
|
2113
|
+
];
|
|
2114
|
+
const BRAND_STYLES = ['', 'bw-popelka', 'bw-myra', 'bw-wicky', 'bw-midcase', 'bw-vremena'];
|
|
2115
|
+
let sigilIdx = Math.floor(Math.random() * SIGILS.length);
|
|
2116
|
+
let brandIdx = Math.floor(Math.random() * BRAND_STYLES.length);
|
|
2117
|
+
function nextSigil() {
|
|
2118
|
+
const s = SIGILS[sigilIdx];
|
|
2119
|
+
const box = $('#brand-sigil'), img = $('#sigil-img'), word = $('#brand-word');
|
|
2120
|
+
img.style.opacity = '0';
|
|
2121
|
+
setTimeout(() => {
|
|
2122
|
+
img.src = 'assets/logos/' + s.f;
|
|
2123
|
+
box.className = 'brand-sigil ' + s.a + (s.disc ? ' disc' : '');
|
|
2124
|
+
img.style.opacity = '1';
|
|
2125
|
+
word.className = BRAND_STYLES[brandIdx];
|
|
2126
|
+
}, 460);
|
|
2127
|
+
sigilIdx = (sigilIdx + 1) % SIGILS.length;
|
|
2128
|
+
brandIdx = (brandIdx + 1) % BRAND_STYLES.length;
|
|
2129
|
+
}
|
|
2130
|
+
nextSigil();
|
|
2131
|
+
setInterval(nextSigil, 16000);
|
|
2132
|
+
|
|
2133
|
+
/* butterflies stir when an instrument is played */
|
|
2134
|
+
['#btn-analyze', '#forge-go', '#meter-go', '#rw-go', '#syn-go'].forEach(sel => {
|
|
2135
|
+
const b = $(sel);
|
|
2136
|
+
if (!b) return;
|
|
2137
|
+
b.addEventListener('click', () => {
|
|
2138
|
+
$$('.mode:not([hidden]) .bfly').forEach(n => {
|
|
2139
|
+
n.classList.remove('flap');
|
|
2140
|
+
void n.offsetWidth;
|
|
2141
|
+
n.classList.add('flap');
|
|
2142
|
+
n.addEventListener('animationend', () => n.classList.remove('flap'), { once: true });
|
|
2143
|
+
});
|
|
2144
|
+
});
|
|
2145
|
+
});
|
|
2146
|
+
|
|
2147
|
+
/* ── the CLI-style stress map, copyable whole ── */
|
|
2148
|
+
function buildRussianStressMap() {
|
|
2149
|
+
const out = [];
|
|
2150
|
+
const m = state.data.meter;
|
|
2151
|
+
const mDef = RU_METER_DEF[m.meter];
|
|
2152
|
+
out.push(`Meter: ${mDef?.ru || m.meterRu} (${m.meter}) — technicality ${(m.score * 100).toFixed(1)}%`);
|
|
2153
|
+
out.push(`Rhyme scheme: ${state.data.rhymeScheme}`);
|
|
2154
|
+
out.push('');
|
|
2155
|
+
let gline = 0; // rhymes are indexed by poem-global line number
|
|
2156
|
+
state.data.stanzas.forEach((st, si) => {
|
|
2157
|
+
out.push(`Stanza ${si + 1}`);
|
|
2158
|
+
st.lines.forEach((ln, li) => {
|
|
2159
|
+
const tag = `S${si + 1}L${li + 1}`;
|
|
2160
|
+
const pattern = ln.stressPattern.replace(/S/g, 'S').replace(/U/g, 'u');
|
|
2161
|
+
const rhyme = state.data.rhymes?.[gline++];
|
|
2162
|
+
const rLetter = rhyme && rhyme.letter !== '-' ? ' ' + rhyme.letter : '';
|
|
2163
|
+
out.push(` ${tag} ${pattern} ${rLetter} ${ln.raw}`);
|
|
2164
|
+
});
|
|
2165
|
+
out.push('');
|
|
2166
|
+
});
|
|
2167
|
+
return out.join('\n').trimEnd() + '\n';
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
function buildStressMap() {
|
|
2171
|
+
if (state.data?._lang === 'ru') { return buildRussianStressMap(); }
|
|
2172
|
+
const out = [];
|
|
2173
|
+
state.data.stanzas.forEach((st, si) => {
|
|
2174
|
+
out.push(`Stanza ${si + 1}${st.formNote ? ' ¶ ' + st.formNote : ''}`);
|
|
2175
|
+
st.lines.forEach((ln, li) => {
|
|
2176
|
+
const tag = `S${si + 1}L${li + 1}`;
|
|
2177
|
+
if (!ln.parsed) { out.push(` ${tag} (no parse) ${ln.raw}`); return; }
|
|
2178
|
+
const d = ln.detail;
|
|
2179
|
+
const top = (d.ranking ?? []).slice(0, 3).map(r => `${r.meter.slice(0, 5)} ${r.score.toFixed(2)}`).join(' · ');
|
|
2180
|
+
const rhyme = d.rhyme?.letter && d.rhyme.letter !== '·'
|
|
2181
|
+
? ` ${d.rhyme.letter}${d.rhyme.type ? '(' + d.rhyme.type + ')' : ''}` : '';
|
|
2182
|
+
const notes = [d.rhythmNote ? '♪' : '', d.standaloneMeter ? `≈ standalone: ${d.standaloneMeter}` : ''].filter(Boolean).join(' ');
|
|
2183
|
+
out.push(` ${tag} ${d.scansion} ${d.meter}${top ? ' | ' + top : ''}${rhyme}${notes ? ' ' + notes : ''}`);
|
|
2184
|
+
});
|
|
2185
|
+
out.push('');
|
|
2186
|
+
});
|
|
2187
|
+
return out.join('\n').trimEnd() + '\n';
|
|
2188
|
+
}
|
|
2189
|
+
$('#btn-copymap').addEventListener('click', async () => {
|
|
2190
|
+
if (!state.data) { toast('Scan a poem first.'); return; }
|
|
2191
|
+
try {
|
|
2192
|
+
await navigator.clipboard.writeText(buildStressMap());
|
|
2193
|
+
toast('Stress map copied — every line’s keys.');
|
|
2194
|
+
} catch {
|
|
2195
|
+
toast('The clipboard refused — select and copy from the codex instead.');
|
|
2196
|
+
}
|
|
2197
|
+
});
|
|
2198
|
+
|
|
2199
|
+
/* ── mode tabs ── */
|
|
2200
|
+
$$('.mode-tab').forEach(tab => tab.addEventListener('click', () => {
|
|
2201
|
+
$$('.mode-tab').forEach(t => {
|
|
2202
|
+
const on = t === tab;
|
|
2203
|
+
t.classList.toggle('is-active', on);
|
|
2204
|
+
t.setAttribute('aria-selected', String(on));
|
|
2205
|
+
});
|
|
2206
|
+
['scansion', 'forge', 'rewrites', 'syntax'].forEach(m => {
|
|
2207
|
+
$(`#mode-${m}`).hidden = m !== tab.dataset.mode;
|
|
2208
|
+
});
|
|
2209
|
+
els.tooltip.hidden = true;
|
|
2210
|
+
}));
|
|
2211
|
+
|
|
2212
|
+
/* ── the Rhyme Forge ── */
|
|
2213
|
+
async function runForge() {
|
|
2214
|
+
const word = $('#forge-word').value.trim();
|
|
2215
|
+
const out = $('#forge-out');
|
|
2216
|
+
if (!word) { toast('Give the forge a word first.'); return; }
|
|
2217
|
+
out.innerHTML = '';
|
|
2218
|
+
out.append(el('p', { class: 'tool-note', text: 'forging…' }));
|
|
2219
|
+
try {
|
|
2220
|
+
const syll = $('#forge-syll').value;
|
|
2221
|
+
const url = '/api/word?w=' + encodeURIComponent(word) + (syll ? '&syll=' + syll : '');
|
|
2222
|
+
const r = await fetch(url);
|
|
2223
|
+
const d = await r.json();
|
|
2224
|
+
if (!r.ok || d.error) throw new Error(d.error || 'no answer');
|
|
2225
|
+
out.innerHTML = '';
|
|
2226
|
+
// the word's dossier, reusing the Inspector's renderer
|
|
2227
|
+
out.append(renderDossier(d));
|
|
2228
|
+
const pool = syll ? d.rhymesBySyll : d.rhymes;
|
|
2229
|
+
const label = syll ? `strict rhymes of exactly ${syll} syllable${syll === '1' ? '' : 's'}` : 'strict rhymes';
|
|
2230
|
+
out.append(el('div', { class: 'insp-section' },
|
|
2231
|
+
el('div', { class: 'insp-label', text: label }),
|
|
2232
|
+
pool?.length
|
|
2233
|
+
? el('div', { class: 'word-cloud' }, pool.map(x => rcChip(x, () => { $('#forge-word').value = x; runForge(); })))
|
|
2234
|
+
: el('p', { class: 'tool-note', text: 'none in the lexicon — try loosening the syllable filter.' })));
|
|
2235
|
+
} catch (err) {
|
|
2236
|
+
out.innerHTML = '';
|
|
2237
|
+
out.append(el('p', { class: 'tool-note', text: 'The forge went cold: ' + err.message }));
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
function rcChip(text, onclick) {
|
|
2241
|
+
return el('span', { class: 'rc', text, title: 'click to forge this word', onclick });
|
|
2242
|
+
}
|
|
2243
|
+
$('#forge-go').addEventListener('click', runForge);
|
|
2244
|
+
$('#forge-word').addEventListener('keydown', (e) => { if (e.key === 'Enter') runForge(); });
|
|
2245
|
+
|
|
2246
|
+
async function runMeterMatch() {
|
|
2247
|
+
const pattern = $('#meter-pattern').value.replace(/[^012]/g, '');
|
|
2248
|
+
const out = $('#meter-out');
|
|
2249
|
+
if (!pattern) { toast('A pattern needs digits: 0, 1, 2.'); return; }
|
|
2250
|
+
out.innerHTML = '';
|
|
2251
|
+
out.append(el('p', { class: 'tool-note', text: 'summoning…' }));
|
|
2252
|
+
try {
|
|
2253
|
+
const r = await fetch('/api/meter?pattern=' + pattern);
|
|
2254
|
+
const d = await r.json();
|
|
2255
|
+
if (!r.ok || d.error) throw new Error(d.error || 'no answer');
|
|
2256
|
+
out.innerHTML = '';
|
|
2257
|
+
out.append(el('p', { class: 'tool-note', text: `${d.total} words in the lexicon carry the contour ${d.pattern} — a fresh handful each casting:` }));
|
|
2258
|
+
out.append(el('div', { class: 'word-cloud' }, d.words.map(x => rcChip(x, () => { $('#forge-word').value = x; runForge(); window.scrollTo({ top: 0, behavior: 'smooth' }); }))));
|
|
2259
|
+
} catch (err) {
|
|
2260
|
+
out.innerHTML = '';
|
|
2261
|
+
out.append(el('p', { class: 'tool-note', text: 'The summoning failed: ' + err.message }));
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
$('#meter-go').addEventListener('click', runMeterMatch);
|
|
2265
|
+
$('#meter-pattern').addEventListener('keydown', (e) => { if (e.key === 'Enter') runMeterMatch(); });
|
|
2266
|
+
|
|
2267
|
+
/* ── the Transmutation Chamber ── */
|
|
2268
|
+
$('#rw-pos').addEventListener('input', () => { $('#rw-pos-val').textContent = $('#rw-pos').value; });
|
|
2269
|
+
$('#rw-freq').addEventListener('input', () => { $('#rw-freq-val').textContent = Number($('#rw-freq').value).toFixed(2); });
|
|
2270
|
+
$('#rw-reg').addEventListener('input', () => { $('#rw-reg-val').textContent = $('#rw-reg').value; });
|
|
2271
|
+
/* fuzzy rhymes apply only to rhyme mode */
|
|
2272
|
+
$('#rw-mode').addEventListener('change', () => {
|
|
2273
|
+
$('#rw-fuzzy-field').hidden = $('#rw-mode').value !== 'rhyme';
|
|
2274
|
+
});
|
|
2275
|
+
async function runRewrite() {
|
|
2276
|
+
const text = $('#rw-input').value.trim();
|
|
2277
|
+
const out = $('#rw-out');
|
|
2278
|
+
if (!text) { toast('The chamber is empty — give it some verse.'); return; }
|
|
2279
|
+
const btn = $('#rw-go');
|
|
2280
|
+
btn.disabled = true;
|
|
2281
|
+
try {
|
|
2282
|
+
const r = await fetch('/api/rewrite', {
|
|
2283
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
2284
|
+
body: JSON.stringify({
|
|
2285
|
+
text, mode: $('#rw-mode').value,
|
|
2286
|
+
posPrecision: +$('#rw-pos').value,
|
|
2287
|
+
freqThreshold: +$('#rw-freq').value,
|
|
2288
|
+
fuzzyRhyme: $('#rw-mode').value === 'rhyme' && $('#rw-fuzzy').checked,
|
|
2289
|
+
morphGround: $('#rw-morph').checked,
|
|
2290
|
+
registerFidelity: +$('#rw-reg').value,
|
|
2291
|
+
dictPos: $('#rw-dictpos').checked,
|
|
2292
|
+
}),
|
|
2293
|
+
});
|
|
2294
|
+
const d = await r.json();
|
|
2295
|
+
if (!r.ok || d.error) throw new Error(d.error || 'no answer');
|
|
2296
|
+
const result = el('div', { class: 'rw-result', text: d.output });
|
|
2297
|
+
out.prepend(result);
|
|
2298
|
+
while (out.children.length > 4) out.lastChild.remove(); // keep the last few castings
|
|
2299
|
+
} catch (err) {
|
|
2300
|
+
toast('The transmutation fizzled: ' + err.message);
|
|
2301
|
+
} finally { btn.disabled = false; }
|
|
2302
|
+
}
|
|
2303
|
+
$('#rw-go').addEventListener('click', runRewrite);
|
|
2304
|
+
|
|
2305
|
+
/* ── the Parse Observatory ── */
|
|
2306
|
+
async function runSyntax() {
|
|
2307
|
+
let text = $('#syn-input').value.trim();
|
|
2308
|
+
const out = $('#syn-out');
|
|
2309
|
+
if (!text) { toast('Give the observatory a sentence or a stanza.'); return; }
|
|
2310
|
+
if ($('#syn-fuse').checked) text = text.replace(/\s*\n+\s*/g, ' ');
|
|
2311
|
+
out.innerHTML = '';
|
|
2312
|
+
out.append(el('p', { class: 'tool-note', text: 'parsing…' }));
|
|
2313
|
+
try {
|
|
2314
|
+
// Cyrillic text routes to the Russian pipeline (omit the engine choice so
|
|
2315
|
+
// the server's auto-detection is free to pick it).
|
|
2316
|
+
const cyr = (text.match(/[А-Яа-яЁё]/g) || []).length;
|
|
2317
|
+
const lat = (text.match(/[A-Za-z]/g) || []).length;
|
|
2318
|
+
const isRu = cyr > lat && cyr > 0;
|
|
2319
|
+
const r = await fetch('/api/analyze', {
|
|
2320
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
2321
|
+
body: JSON.stringify(isRu ? { text } : { text, engine: state.engine }),
|
|
2322
|
+
});
|
|
2323
|
+
const d = await r.json();
|
|
2324
|
+
if (!r.ok || d.error) throw new Error(d.error || 'no answer');
|
|
2325
|
+
out.innerHTML = '';
|
|
2326
|
+
if (d.engine === 'russian') {
|
|
2327
|
+
d.stanzas.forEach(st => st.lines.forEach(line => {
|
|
2328
|
+
if (!line.words?.length) return;
|
|
2329
|
+
const block = el('div', { class: 'syn-line' });
|
|
2330
|
+
block.append(el('div', { class: 'syn-raw', text: line.raw }));
|
|
2331
|
+
const stage = el('div', { class: 'dep-stage' }, el('div', { class: 'dep-svg-slot' }),
|
|
2332
|
+
el('div', { class: 'dep-words' }, line.words.map(w =>
|
|
2333
|
+
el('div', { class: `dep-word ${w.isContent ? 'is-content' : 'is-function'}` },
|
|
2334
|
+
el('span', { class: 'dw-text', text: w.form }),
|
|
2335
|
+
el('span', { class: 'dw-pos', text: w.upos, title: RU_UPOS_GLOSS[w.upos] ?? w.upos })))));
|
|
2336
|
+
block.append(stage);
|
|
2337
|
+
const table = el('table', { class: 'syn-table' },
|
|
2338
|
+
el('tr', {},
|
|
2339
|
+
el('th', { text: 'word' }),
|
|
2340
|
+
el('th', { text: 'part of speech', title: 'UPOS from the Russian SynTagRus UDPipe model (no XPOS layer)' }),
|
|
2341
|
+
el('th', { text: 'features', title: 'UD morphological FEATS from UDPipe' }),
|
|
2342
|
+
el('th', { text: 'grammatical bond', title: 'the dependency relation to its governor' })),
|
|
2343
|
+
line.words.map((w, wi) => {
|
|
2344
|
+
const dep = (line.deps ?? []).find(dp => dp.from === wi);
|
|
2345
|
+
const gov = dep && dep.to >= 0 ? line.words[dep.to] : null;
|
|
2346
|
+
return el('tr', {},
|
|
2347
|
+
el('td', { class: 'w', text: w.form }),
|
|
2348
|
+
el('td', {}, el('abbr', { text: w.upos, title: RU_UPOS_GLOSS[w.upos] ?? w.upos })),
|
|
2349
|
+
el('td', {}, w.feats && Object.keys(w.feats).length
|
|
2350
|
+
? Object.entries(w.feats).map(([k, v]) =>
|
|
2351
|
+
el('span', { class: 'feat-chip', title: `${k}=${v}`, html: `${esc(k)}: <b>${esc(v)}</b>` }))
|
|
2352
|
+
: el('span', { text: '—' })),
|
|
2353
|
+
el('td', {}, dep
|
|
2354
|
+
? el('abbr', {
|
|
2355
|
+
text: dep.to === -1 ? 'root' : `←${dep.rel}← ${gov?.form ?? '?'}`,
|
|
2356
|
+
title: dep.to === -1 ? (DEP_GLOSS.root ?? 'root') : `${DEP_GLOSS[dep.rel] ?? dep.rel} “${gov?.form ?? '?'}”`,
|
|
2357
|
+
})
|
|
2358
|
+
: el('span', { text: '—' })));
|
|
2359
|
+
}));
|
|
2360
|
+
block.append(table);
|
|
2361
|
+
out.append(block);
|
|
2362
|
+
drawDepArcs(block, { deps: line.deps ?? [], words: line.words.map(w => ({ text: w.form })) });
|
|
2363
|
+
}));
|
|
2364
|
+
return;
|
|
2365
|
+
}
|
|
2366
|
+
d.stanzas.forEach(st => st.lines.forEach(line => {
|
|
2367
|
+
if (!line.parsed) return;
|
|
2368
|
+
const block = el('div', { class: 'syn-line' });
|
|
2369
|
+
block.append(el('div', { class: 'syn-raw', text: line.raw }));
|
|
2370
|
+
// arcs, reusing the laboratory's renderer
|
|
2371
|
+
const stage = el('div', { class: 'dep-stage' }, el('div', { class: 'dep-svg-slot' }),
|
|
2372
|
+
el('div', { class: 'dep-words' }, line.words.map(w =>
|
|
2373
|
+
el('div', { class: `dep-word ${w.isContent ? 'is-content' : 'is-function'}` },
|
|
2374
|
+
el('span', { class: 'dw-text', text: w.text }),
|
|
2375
|
+
el('span', { class: 'dw-pos', text: w.pos, title: POS_GLOSS[w.pos] ?? w.pos })))));
|
|
2376
|
+
block.append(stage);
|
|
2377
|
+
// the register: word · POS · features · bond
|
|
2378
|
+
const table = el('table', { class: 'syn-table' },
|
|
2379
|
+
el('tr', {},
|
|
2380
|
+
el('th', { text: 'word' }),
|
|
2381
|
+
el('th', { text: 'part of speech', title: 'Penn Treebank tag, converted from UDPipe’s UPOS' }),
|
|
2382
|
+
el('th', { text: 'features', title: 'UD morphological FEATS from UDPipe' }),
|
|
2383
|
+
el('th', { text: 'grammatical bond', title: 'the dependency relation to its governor' })),
|
|
2384
|
+
line.words.map(w => el('tr', {},
|
|
2385
|
+
el('td', { class: 'w', text: w.text }),
|
|
2386
|
+
el('td', {}, el('abbr', { text: w.pos, title: POS_GLOSS[w.pos] ?? w.pos })),
|
|
2387
|
+
el('td', {}, w.feats
|
|
2388
|
+
? Object.entries(w.feats).map(([k, v]) => {
|
|
2389
|
+
const { k: kk, v: vv } = featPhrase(k, v);
|
|
2390
|
+
return el('span', { class: 'feat-chip', title: `${k}=${v}`, html: `${esc(kk)}: <b>${esc(vv)}</b>` });
|
|
2391
|
+
})
|
|
2392
|
+
: el('span', { text: '—' })),
|
|
2393
|
+
el('td', {}, w.dep
|
|
2394
|
+
? el('abbr', { text: w.dep.isRoot ? 'root' : `←${w.dep.rel}← ${w.dep.govWord}`, title: w.dep.isRoot ? DEP_GLOSS.root : `${DEP_GLOSS[w.dep.rel] ?? w.dep.rel} “${w.dep.govWord}”` })
|
|
2395
|
+
: el('span', { text: '—' })))));
|
|
2396
|
+
block.append(table);
|
|
2397
|
+
out.append(block);
|
|
2398
|
+
drawDepArcs(block, line);
|
|
2399
|
+
}));
|
|
2400
|
+
} catch (err) {
|
|
2401
|
+
out.innerHTML = '';
|
|
2402
|
+
out.append(el('p', { class: 'tool-note', text: 'The parse clouded over: ' + err.message }));
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
2405
|
+
$('#syn-go').addEventListener('click', runSyntax);
|
|
2406
|
+
|
|
2407
|
+
/* ── served-from-disk guard: fetch can never reach the engine on file:// ── */
|
|
2408
|
+
if (location.protocol === 'file:') {
|
|
2409
|
+
$('#file-warning').hidden = false;
|
|
2410
|
+
$('#btn-analyze').disabled = true;
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
$('#btn-legend').addEventListener('click', () => { els.legend.hidden = false; });
|
|
2414
|
+
$('#btn-legend-close').addEventListener('click', () => { els.legend.hidden = true; });
|
|
2415
|
+
els.legend.addEventListener('click', (e) => { if (e.target === els.legend) els.legend.hidden = true; });
|
|
2416
|
+
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') els.legend.hidden = true; });
|
|
2417
|
+
|
|
2418
|
+
buildLegend();
|