diff --git a/app.py b/app.py
index 62f8bd2..ec3d964 100644
--- a/app.py
+++ b/app.py
@@ -1035,9 +1035,20 @@ def analyze(draft: Draft):
cluster.append(t)
flush(cluster)
+ # unanswered endings: line-ends still waiting for a rhyme partner —
+ # the open loops that tell a writer where to strike next
+ open_out = []
+ last_tok = {}
+ for t in tokens:
+ if t["is_end"]:
+ last_tok[t["line"]] = t
+ for i, t in last_tok.items():
+ if i not in end_gid:
+ open_out.append({"l": i, "s": t["start"], "e": t["end"]})
+
return {"lines": lines, "tokens": toks_out, "groups": groups_out,
"stanzas": stanzas, "meter": meter, "stress": stress_out,
- "allit": allit_out}
+ "allit": allit_out, "open": open_out}
# --------------------------------------------------------------------------
diff --git a/static/index.html b/static/index.html
index 9c02a90..91af0cf 100644
--- a/static/index.html
+++ b/static/index.html
@@ -510,11 +510,13 @@ function render(){
const lines = editor.value.split('\n');
const tokByLine = {};
const allitByLine = {};
+ const openByLine = {};
const groupInfo = {};
if(analysis){
analysis.groups.forEach(g=>{ groupInfo[g.id] = g; });
analysis.tokens.forEach(t=>{ (tokByLine[t.l] ||= []).push(t); });
if(analysis.allit) analysis.allit.forEach(t=>{ (allitByLine[t.l] ||= []).push(t); });
+ if(analysis.open) analysis.open.forEach(t=>{ (openByLine[t.l] ||= []).push(t); });
}
const colorOf = t => `var(--r${groupInfo[t.g].color % COLORS})`;
let html = '';
@@ -537,13 +539,15 @@ function render(){
const words = toks.filter(t=>!t.ph);
const phrases = toks.filter(t=>t.ph);
const als = (allitToggle.checked && fresh) ? (allitByLine[i] || []) : [];
+ const opens = (rhymeToggle.checked && fresh) ? (openByLine[i] || []) : [];
let h = '';
- if(!toks.length && !als.length){
+ if(!toks.length && !als.length && !opens.length){
h = esc(line);
}else{
const cuts = new Set([0, line.length]);
toks.forEach(t=>{ cuts.add(t.s); cuts.add(t.e); });
als.forEach(t=>{ cuts.add(t.s); cuts.add(t.e); });
+ opens.forEach(t=>{ cuts.add(t.s); cuts.add(t.e); });
const pts = [...cuts].sort((a,b)=>a-b);
for(let k = 0; k < pts.length - 1; k++){
const a = pts[k], b = pts[k+1];
@@ -552,13 +556,17 @@ function render(){
const w = words.find(t=>t.s <= a && b <= t.e);
const p = phrases.find(t=>t.s <= a && b <= t.e);
const al = als.find(t=>t.s <= a && b <= t.e);
- if(!w && !p && !al){ h += text; continue; }
- // fills = tail sound (rhyme); underline = head sound (alliteration)
+ const op = opens.find(t=>t.s <= a && b <= t.e);
+ if(!w && !p && !al && !op){ h += text; continue; }
+ // fills = tail sound (rhyme); underline = head sound (alliteration);
+ // gray = an ending still waiting for its answer
let style = '';
if(w || p){
const alpha = w ? (w.end ? 34 : 14) : (p.end ? 24 : 10);
const color = w ? colorOf(w) : colorOf(p);
style += `background:color-mix(in srgb, ${color} ${alpha}%, transparent);`;
+ }else if(op){
+ style += `background:color-mix(in srgb, var(--ink-dim) 13%, transparent);`;
}
if(al) style += `box-shadow:inset 0 -2px 0 0 color-mix(in srgb, var(--r${al.g % COLORS}) 75%, transparent);`;
h += `${text}`;
@@ -573,10 +581,27 @@ function render(){
buildReadout();
}
+function cadenceColors(){
+ // exact stress-contour matches (5+ syllables) form a flow family
+ const map = {};
+ if(!analysis || !analysis.meter) return map;
+ const byPat = {};
+ analysis.meter.forEach(m=>{
+ if(m.stress && m.stress.length >= 5) (byPat[m.stress] ||= []).push(m.l);
+ });
+ let fid = 0;
+ Object.keys(byPat).sort().forEach(pat=>{
+ const lns = byPat[pat];
+ if(lns.length >= 2){ lns.forEach(l=>{ map[l] = fid % COLORS; }); fid++; }
+ });
+ return map;
+}
+
function renderStress(lines){
if(!stressToggle.checked){ stresslayer.innerHTML = ''; return; }
const byLine = {};
if(analysis && analysis.stress) analysis.stress.forEach(s=>{ (byLine[s.l] ||= []).push(s); });
+ const cmap = cadenceColors();
let html = '';
lines.forEach((line, i)=>{
const fresh = analysis && analysis.lines[i] === line;
@@ -586,7 +611,8 @@ function renderStress(lines){
if(s.s < pos) return;
h2 += esc(line.slice(pos, s.s));
const dots = [...s.st].map(c=> c === '0' ? '○' : '●').join('');
- h2 += `${esc(line.slice(s.s, s.e))}${dots}`;
+ const col = cmap[i] != null ? ` style="color:var(--r${cmap[i]})"` : '';
+ h2 += `${esc(line.slice(s.s, s.e))}${dots}`;
pos = s.e;
});
h2 += esc(line.slice(pos));
diff --git a/tests/test_rhymes.py b/tests/test_rhymes.py
index aa6af16..7b7d393 100644
--- a/tests/test_rhymes.py
+++ b/tests/test_rhymes.py
@@ -560,3 +560,10 @@ def test_alliteration_needs_three_and_locality():
def test_word_senses():
from app import word_info
assert word_info(word="light")["senses"] >= 10
+
+
+def test_unanswered_endings_reported():
+ res = analyze(Draft(text="the cat\nso blue\na hat\nthe end"))
+ opens = {res["lines"][o["l"]][o["s"]:o["e"]] for o in res["open"]}
+ assert "blue" in opens and "end" in opens
+ assert "hat" not in opens and "cat" not in opens