update ui and add new audio components
This commit is contained in:
@@ -5,6 +5,14 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Stellarium NOW Wrapper</title>
|
||||
<style>
|
||||
:root {
|
||||
--sky-embed-width: 100%;
|
||||
--sky-embed-height: 106%;
|
||||
--sky-embed-left: 0%;
|
||||
--sky-embed-top: -2%;
|
||||
--sky-brand-mask-width: 0%;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
@@ -32,14 +40,45 @@
|
||||
|
||||
#sky-embed {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 106%;
|
||||
top: -2%;
|
||||
left: var(--sky-embed-left);
|
||||
top: var(--sky-embed-top);
|
||||
width: var(--sky-embed-width);
|
||||
height: var(--sky-embed-height);
|
||||
border: 0;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
#sky-brand-mask {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: var(--sky-brand-mask-width);
|
||||
pointer-events: none;
|
||||
background: linear-gradient(90deg, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 0.96) 58%, rgba(0, 0, 0, 0) 100%);
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
:root {
|
||||
--sky-embed-width: 122%;
|
||||
--sky-embed-height: 112%;
|
||||
--sky-embed-left: -10%;
|
||||
--sky-embed-top: -3%;
|
||||
--sky-brand-mask-width: 12%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
:root {
|
||||
--sky-embed-width: 126%;
|
||||
--sky-embed-height: 116%;
|
||||
--sky-embed-left: -12%;
|
||||
--sky-embed-top: -4%;
|
||||
--sky-brand-mask-width: 15%;
|
||||
}
|
||||
}
|
||||
|
||||
#sky-shell::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
@@ -76,6 +115,7 @@
|
||||
<body>
|
||||
<div id="sky-shell">
|
||||
<iframe id="sky-embed" title="Decorative sky background" scrolling="no" allow="geolocation"></iframe>
|
||||
<div id="sky-brand-mask" aria-hidden="true"></div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
|
||||
542
app/styles.css
542
app/styles.css
@@ -137,6 +137,10 @@
|
||||
background: #3f3f46;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
body.topbar-menu-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
@@ -156,9 +160,34 @@
|
||||
min-height: 38px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.topbar.is-menu-open {
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-end;
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
.topbar.is-menu-open .topbar-home-button {
|
||||
display: none;
|
||||
}
|
||||
.topbar-actions {
|
||||
max-height: calc(100svh - 88px);
|
||||
}
|
||||
.topbar.is-menu-open .topbar-actions {
|
||||
position: absolute;
|
||||
top: calc(100% - 1px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: auto;
|
||||
min-height: calc(100svh - 58px);
|
||||
max-height: calc(100svh - 58px);
|
||||
margin: 0;
|
||||
padding: 14px 12px calc(18px + env(safe-area-inset-bottom, 0px));
|
||||
border-radius: 0 0 18px 18px;
|
||||
border-left: 0;
|
||||
border-right: 0;
|
||||
border-bottom: 0;
|
||||
box-shadow: 0 22px 44px rgba(0, 0, 0, 0.44);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.settings-trigger {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
@@ -175,6 +204,12 @@
|
||||
border-radius: 14px;
|
||||
max-height: calc(100svh - 78px);
|
||||
}
|
||||
.topbar.is-menu-open .topbar-actions {
|
||||
min-height: calc(100svh - 52px);
|
||||
max-height: calc(100svh - 52px);
|
||||
padding: 12px 10px calc(18px + env(safe-area-inset-bottom, 0px));
|
||||
border-radius: 0;
|
||||
}
|
||||
.settings-trigger {
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -3338,7 +3373,7 @@
|
||||
}
|
||||
|
||||
.alpha-text-controls--heading {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
padding: 14px;
|
||||
border: 1px solid #2f2f39;
|
||||
border-radius: 14px;
|
||||
@@ -3398,6 +3433,14 @@
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.alpha-text-control[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.alpha-text-control--toggle {
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.alpha-text-control > span {
|
||||
color: #a1a1aa;
|
||||
font-size: 11px;
|
||||
@@ -3416,6 +3459,11 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.alpha-text-compare-toggle {
|
||||
min-height: 42px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.alpha-text-search-input {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
@@ -3482,6 +3530,10 @@
|
||||
.alpha-text-heading-tools {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.alpha-text-reader-compare {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
@@ -3572,6 +3624,17 @@
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.alpha-text-reader-compare {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.alpha-text-reader-card--compare {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.alpha-text-reader-navigation {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -4210,6 +4273,409 @@
|
||||
}
|
||||
#elements-section[hidden] { display: none; }
|
||||
|
||||
/* ── Audio Notes section ─────────────────────────────────────────────── */
|
||||
#audio-notes-section {
|
||||
height: calc(100vh - 61px);
|
||||
background: #18181b;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
#audio-notes-section[hidden] { display: none; }
|
||||
|
||||
#audio-circle-section {
|
||||
height: calc(100vh - 61px);
|
||||
background: #18181b;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
#audio-circle-section[hidden] { display: none; }
|
||||
|
||||
.audio-circle-layout {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(340px, 1.1fr) minmax(320px, 0.9fr);
|
||||
min-height: 0;
|
||||
background: #18181b;
|
||||
}
|
||||
|
||||
.audio-circle-stage-panel {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
gap: 0;
|
||||
min-height: 0;
|
||||
border-right: 1px solid #27272a;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.audio-circle-intro {
|
||||
padding: 10px 12px 0;
|
||||
color: #a1a1aa;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.audio-circle-stage {
|
||||
position: relative;
|
||||
min-height: 620px;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
background:
|
||||
radial-gradient(circle at center, rgba(99, 102, 241, 0.08), transparent 46%),
|
||||
linear-gradient(180deg, rgba(15, 15, 23, 0.98), rgba(10, 10, 16, 0.98));
|
||||
}
|
||||
|
||||
.audio-circle-shell {
|
||||
position: relative;
|
||||
width: min(78vw, 560px);
|
||||
height: min(78vw, 560px);
|
||||
min-width: 280px;
|
||||
min-height: 280px;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
margin: 0 auto;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(99, 102, 241, 0.3);
|
||||
background:
|
||||
radial-gradient(circle at center, rgba(12, 12, 18, 0.98) 0, rgba(12, 12, 18, 0.98) 24%, rgba(39, 39, 42, 0.24) 24.4%, rgba(39, 39, 42, 0.24) 54%, rgba(15, 15, 23, 0.98) 54.4%, rgba(15, 15, 23, 0.98) 100%);
|
||||
box-shadow: inset 0 0 0 1px rgba(165, 180, 252, 0.08), 0 24px 50px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.audio-circle-center {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 34%;
|
||||
height: 34%;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(99, 102, 241, 0.24);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
text-align: center;
|
||||
padding: 14px;
|
||||
box-sizing: border-box;
|
||||
background: linear-gradient(180deg, rgba(24, 24, 38, 0.98), rgba(10, 10, 16, 0.98));
|
||||
}
|
||||
|
||||
.audio-circle-center-label {
|
||||
color: #f4f4f5;
|
||||
font-size: clamp(24px, 4vw, 36px);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.audio-circle-center-sub {
|
||||
color: #a1a1aa;
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.audio-circle-key {
|
||||
position: absolute;
|
||||
width: 88px;
|
||||
min-height: 64px;
|
||||
padding: 10px 8px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #3f3f46;
|
||||
background: linear-gradient(180deg, rgba(24, 24, 27, 0.98), rgba(12, 12, 18, 0.98));
|
||||
color: #f4f4f5;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
justify-items: center;
|
||||
box-sizing: border-box;
|
||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.28);
|
||||
transition: transform 120ms ease, border-color 120ms ease, background 120ms ease;
|
||||
}
|
||||
|
||||
.audio-circle-key:hover {
|
||||
transform: translate(-50%, -50%) scale(1.03);
|
||||
border-color: #818cf8;
|
||||
background: linear-gradient(180deg, rgba(49, 46, 129, 0.26), rgba(12, 12, 18, 0.98));
|
||||
}
|
||||
|
||||
.audio-circle-key.is-selected,
|
||||
.audio-circle-key[aria-pressed="true"] {
|
||||
border-color: #a5b4fc;
|
||||
background: linear-gradient(180deg, rgba(67, 56, 202, 0.46), rgba(18, 18, 30, 0.98));
|
||||
box-shadow: 0 0 0 1px rgba(165, 180, 252, 0.22), 0 14px 24px rgba(0, 0, 0, 0.34);
|
||||
}
|
||||
|
||||
.audio-circle-key-major {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.audio-circle-key-minor {
|
||||
color: #c7d2fe;
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.audio-circle-key-meta {
|
||||
color: #71717a;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.audio-circle-detail-panel {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 0 14px 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.audio-circle-detail-stack {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.audio-circle-chip-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.audio-circle-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 9px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #3f3f46;
|
||||
background: #18181b;
|
||||
color: #d4d4d8;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.audio-circle-chip--active {
|
||||
border-color: #4338ca;
|
||||
background: rgba(67, 56, 202, 0.18);
|
||||
color: #c7d2fe;
|
||||
}
|
||||
|
||||
.audio-circle-copy {
|
||||
color: #d4d4d8;
|
||||
font-size: 13px;
|
||||
line-height: 1.58;
|
||||
}
|
||||
|
||||
.audio-circle-nav-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.audio-circle-nav-card {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 10px;
|
||||
border: 1px solid #2f2f39;
|
||||
border-radius: 10px;
|
||||
background: #0c0c12;
|
||||
}
|
||||
|
||||
.audio-circle-nav-label {
|
||||
color: #a1a1aa;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.audio-circle-nav-value {
|
||||
color: #f4f4f5;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.audio-circle-nav-sub {
|
||||
color: #c7d2fe;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@media (max-width: 1040px) {
|
||||
.audio-circle-layout {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.audio-circle-stage-panel {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid #27272a;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.audio-circle-stage {
|
||||
min-height: 500px;
|
||||
padding: 18px 12px;
|
||||
}
|
||||
|
||||
.audio-circle-shell {
|
||||
width: min(92vw, 420px);
|
||||
height: min(92vw, 420px);
|
||||
}
|
||||
|
||||
.audio-circle-key {
|
||||
width: 68px;
|
||||
min-height: 52px;
|
||||
padding: 8px 6px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.audio-circle-key-major {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.audio-circle-key-minor,
|
||||
.audio-circle-key-meta {
|
||||
font-size: 9px;
|
||||
}
|
||||
}
|
||||
|
||||
.audio-note-intro {
|
||||
padding: 10px 12px 0;
|
||||
color: #a1a1aa;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.audio-note-detail-stack {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.audio-note-chip-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.audio-note-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 9px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #3f3f46;
|
||||
background: #18181b;
|
||||
color: #d4d4d8;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.audio-note-chip--natural {
|
||||
border-color: #365314;
|
||||
background: rgba(22, 101, 52, 0.16);
|
||||
color: #bbf7d0;
|
||||
}
|
||||
|
||||
.audio-note-chip--accidental {
|
||||
border-color: #4338ca;
|
||||
background: rgba(67, 56, 202, 0.18);
|
||||
color: #c7d2fe;
|
||||
}
|
||||
|
||||
.audio-note-copy {
|
||||
color: #d4d4d8;
|
||||
font-size: 13px;
|
||||
line-height: 1.58;
|
||||
}
|
||||
|
||||
.audio-note-formula {
|
||||
color: #d4d4d8;
|
||||
font-size: 13px;
|
||||
line-height: 1.58;
|
||||
}
|
||||
|
||||
.audio-note-playback-copy {
|
||||
color: #d4d4d8;
|
||||
font-size: 13px;
|
||||
line-height: 1.58;
|
||||
}
|
||||
|
||||
.audio-note-playback-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.audio-note-playback-btn {
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.audio-note-frequency-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.audio-note-frequency-cell {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 10px;
|
||||
border: 1px solid #2f2f39;
|
||||
border-radius: 10px;
|
||||
background: #0c0c12;
|
||||
}
|
||||
|
||||
.audio-note-frequency-cell--button {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color 120ms ease, background 120ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
.audio-note-frequency-cell--button:hover {
|
||||
border-color: #6366f1;
|
||||
background: #121224;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.audio-note-frequency-cell--button.is-playing,
|
||||
.audio-note-frequency-cell--button[aria-pressed="true"] {
|
||||
border-color: #a5b4fc;
|
||||
background: linear-gradient(180deg, rgba(49, 46, 129, 0.42), rgba(12, 12, 18, 0.98));
|
||||
box-shadow: 0 0 0 1px rgba(129, 140, 248, 0.26);
|
||||
}
|
||||
|
||||
.audio-note-frequency-label {
|
||||
color: #a1a1aa;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.audio-note-frequency-value {
|
||||
color: #f4f4f5;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.audio-note-frequency-meta {
|
||||
color: #71717a;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.audio-note-frequency-status {
|
||||
color: #c7d2fe;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.audio-note-frequency-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.enoch-list-item {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
@@ -4683,6 +5149,10 @@
|
||||
}
|
||||
#now-panel {
|
||||
--now-square-size: min(85vmin, calc(100vw - 172px), calc(100svh - 92px));
|
||||
--now-sky-width: max(calc(var(--now-square-size) * 3.25), calc(100% + 420px));
|
||||
--now-sky-height: max(calc(var(--now-square-size) * 2.28), calc(100% + 250px));
|
||||
--now-sky-top: 50.5%;
|
||||
--now-sky-left: 44%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
height: calc(100svh - 88px);
|
||||
@@ -4701,10 +5171,10 @@
|
||||
|
||||
#now-sky-layer {
|
||||
position: absolute;
|
||||
width: max(calc(var(--now-square-size) * 3.25), calc(100% + 420px));
|
||||
height: max(calc(var(--now-square-size) * 2.28), calc(100% + 250px));
|
||||
top: 50.5%;
|
||||
left: 44%;
|
||||
width: var(--now-sky-width);
|
||||
height: var(--now-sky-height);
|
||||
top: var(--now-sky-top);
|
||||
left: var(--now-sky-left);
|
||||
transform: translate(-50%, -50%);
|
||||
transform-origin: center center;
|
||||
z-index: 0;
|
||||
@@ -4786,6 +5256,34 @@
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
#now-panel {
|
||||
--now-square-size: min(88vw, calc(100svh - 150px), 560px);
|
||||
--now-sky-width: max(198vw, calc(100% + 140px));
|
||||
--now-sky-height: max(126svh, calc(100% + 120px));
|
||||
--now-sky-top: 50.5%;
|
||||
--now-sky-left: 46%;
|
||||
padding: 12px clamp(12px, 4vw, 24px) 16px;
|
||||
}
|
||||
|
||||
#now-sky-layer {
|
||||
filter: saturate(1.02);
|
||||
}
|
||||
|
||||
.now-panel-controls {
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
#now-panel {
|
||||
--now-square-size: min(90vw, calc(100svh - 158px), 480px);
|
||||
--now-sky-width: max(214vw, calc(100% + 96px));
|
||||
--now-sky-height: max(132svh, calc(100% + 96px));
|
||||
--now-sky-left: 45%;
|
||||
}
|
||||
}
|
||||
.now-section {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
@@ -4952,16 +5450,36 @@
|
||||
}
|
||||
.now-stats-planets {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 4px 12px;
|
||||
font-size: clamp(15px, 1.9vmin, 17px);
|
||||
line-height: 1.3;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 6px 14px;
|
||||
font-size: clamp(12px, 1.6vmin, 14px);
|
||||
line-height: 1.24;
|
||||
color: #e2e8f0;
|
||||
align-content: start;
|
||||
align-items: start;
|
||||
min-height: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
.now-stats-planet {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
align-content: start;
|
||||
text-align: left;
|
||||
}
|
||||
.now-stats-planet-sign {
|
||||
font-size: 0.84em;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: inherit;
|
||||
}
|
||||
.now-stats-planet-list {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.now-stats-planet-entry {
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
@@ -5386,7 +5904,7 @@
|
||||
width: min(64%, 122px);
|
||||
}
|
||||
.now-stats-planets {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.planet-layout,
|
||||
.tarot-layout {
|
||||
@@ -5507,6 +6025,7 @@
|
||||
}
|
||||
.now-stats-planets {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 5px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5532,7 +6051,8 @@
|
||||
font-size: 10px;
|
||||
}
|
||||
.now-stats-planets {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 4px 8px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,10 +6,15 @@
|
||||
const state = {
|
||||
initialized: false,
|
||||
catalog: null,
|
||||
selectedSourceGroupId: "",
|
||||
selectedSourceId: "",
|
||||
selectedSourceIdByGroup: {},
|
||||
compareSourceIdByGroup: {},
|
||||
compareModeByGroup: {},
|
||||
selectedWorkId: "",
|
||||
selectedSectionId: "",
|
||||
currentPassage: null,
|
||||
comparePassage: null,
|
||||
lexiconEntry: null,
|
||||
lexiconRequestId: 0,
|
||||
lexiconOccurrenceResults: null,
|
||||
@@ -35,6 +40,12 @@
|
||||
let globalSearchInputEl;
|
||||
let localSearchFormEl;
|
||||
let localSearchInputEl;
|
||||
let translationSelectEl;
|
||||
let translationControlEl;
|
||||
let compareSelectEl;
|
||||
let compareControlEl;
|
||||
let compareToggleEl;
|
||||
let compareToggleControlEl;
|
||||
let workSelectEl;
|
||||
let sectionSelectEl;
|
||||
let detailHeadingEl;
|
||||
@@ -63,6 +74,12 @@
|
||||
globalSearchInputEl = document.getElementById("alpha-text-global-search-input");
|
||||
localSearchFormEl = document.getElementById("alpha-text-local-search-form");
|
||||
localSearchInputEl = document.getElementById("alpha-text-local-search-input");
|
||||
translationSelectEl = document.getElementById("alpha-text-translation-select");
|
||||
translationControlEl = translationSelectEl?.closest?.(".alpha-text-control") || null;
|
||||
compareSelectEl = document.getElementById("alpha-text-compare-select");
|
||||
compareControlEl = compareSelectEl?.closest?.(".alpha-text-control") || null;
|
||||
compareToggleEl = document.getElementById("alpha-text-compare-toggle");
|
||||
compareToggleControlEl = document.getElementById("alpha-text-compare-toggle-control");
|
||||
workSelectEl = document.getElementById("alpha-text-work-select");
|
||||
sectionSelectEl = document.getElementById("alpha-text-section-select");
|
||||
detailHeadingEl = document.querySelector("#alphabet-text-section .alpha-text-detail-heading");
|
||||
@@ -167,13 +184,128 @@
|
||||
return Array.isArray(state.catalog?.sources) ? state.catalog.sources : [];
|
||||
}
|
||||
|
||||
function getSourceGroupId(source) {
|
||||
const metadata = getSourceMetadata(source);
|
||||
return normalizeId(metadata.workKey || source?.id || source?.title);
|
||||
}
|
||||
|
||||
function buildSourceGroups(sources) {
|
||||
const groupsById = new Map();
|
||||
|
||||
(Array.isArray(sources) ? sources : []).forEach((source, index) => {
|
||||
const groupId = getSourceGroupId(source) || `source-group-${index + 1}`;
|
||||
if (!groupsById.has(groupId)) {
|
||||
groupsById.set(groupId, {
|
||||
id: groupId,
|
||||
title: normalizeTextValue(source?.title) || normalizeTextValue(source?.shortTitle) || "Untitled Source",
|
||||
order: index,
|
||||
variants: []
|
||||
});
|
||||
}
|
||||
|
||||
groupsById.get(groupId).variants.push(source);
|
||||
});
|
||||
|
||||
return [...groupsById.values()].sort((left, right) => left.order - right.order);
|
||||
}
|
||||
|
||||
function getSourceGroups() {
|
||||
return Array.isArray(state.catalog?.sourceGroups) ? state.catalog.sourceGroups : [];
|
||||
}
|
||||
|
||||
function findById(entries, value) {
|
||||
const needle = normalizeId(value);
|
||||
return (Array.isArray(entries) ? entries : []).find((entry) => normalizeId(entry?.id) === needle) || null;
|
||||
}
|
||||
|
||||
function getSelectedSourceGroup() {
|
||||
return findById(getSourceGroups(), state.selectedSourceGroupId);
|
||||
}
|
||||
|
||||
function getSourceVariants(group = getSelectedSourceGroup()) {
|
||||
return Array.isArray(group?.variants) ? group.variants : [];
|
||||
}
|
||||
|
||||
function getSourceForGroup(group = getSelectedSourceGroup(), sourceId = state.selectedSourceId) {
|
||||
return findById(getSourceVariants(group), sourceId) || getSourceVariants(group)[0] || null;
|
||||
}
|
||||
|
||||
function findSourceGroupBySourceId(sourceId) {
|
||||
const needle = normalizeId(sourceId);
|
||||
return getSourceGroups().find((group) => getSourceVariants(group).some((source) => normalizeId(source?.id) === needle)) || null;
|
||||
}
|
||||
|
||||
function rememberSelectedSource(group, sourceId) {
|
||||
const groupId = normalizeId(group?.id);
|
||||
const normalizedSourceId = normalizeTextValue(sourceId);
|
||||
if (!groupId || !normalizedSourceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.selectedSourceIdByGroup[groupId] = normalizedSourceId;
|
||||
}
|
||||
|
||||
function rememberCompareSource(group, sourceId) {
|
||||
const groupId = normalizeId(group?.id);
|
||||
const normalizedSourceId = normalizeTextValue(sourceId);
|
||||
if (!groupId || !normalizedSourceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.compareSourceIdByGroup[groupId] = normalizedSourceId;
|
||||
}
|
||||
|
||||
function isCompareAvailable(group = getSelectedSourceGroup()) {
|
||||
return getSourceVariants(group).length > 1;
|
||||
}
|
||||
|
||||
function isCompareModeEnabled(group = getSelectedSourceGroup()) {
|
||||
const groupId = normalizeId(group?.id);
|
||||
return Boolean(groupId && state.compareModeByGroup[groupId] && isCompareAvailable(group));
|
||||
}
|
||||
|
||||
function setCompareModeEnabled(group, isEnabled) {
|
||||
const groupId = normalizeId(group?.id);
|
||||
if (!groupId) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.compareModeByGroup[groupId] = Boolean(isEnabled);
|
||||
}
|
||||
|
||||
function getCompareCandidates(group = getSelectedSourceGroup()) {
|
||||
const activeSourceId = normalizeId(state.selectedSourceId);
|
||||
return getSourceVariants(group).filter((source) => normalizeId(source?.id) !== activeSourceId);
|
||||
}
|
||||
|
||||
function getCompareSource(group = getSelectedSourceGroup()) {
|
||||
const groupId = normalizeId(group?.id);
|
||||
const candidates = getCompareCandidates(group);
|
||||
const rememberedSourceId = groupId ? state.compareSourceIdByGroup[groupId] : "";
|
||||
return findById(candidates, rememberedSourceId) || candidates[0] || null;
|
||||
}
|
||||
|
||||
function syncCompareSelection(group = getSelectedSourceGroup()) {
|
||||
const groupId = normalizeId(group?.id);
|
||||
if (!groupId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isCompareAvailable(group)) {
|
||||
delete state.compareSourceIdByGroup[groupId];
|
||||
delete state.compareModeByGroup[groupId];
|
||||
return;
|
||||
}
|
||||
|
||||
const compareSource = getCompareSource(group);
|
||||
if (compareSource?.id) {
|
||||
rememberCompareSource(group, compareSource.id);
|
||||
}
|
||||
}
|
||||
|
||||
function getSelectedSource() {
|
||||
return findById(getSources(), state.selectedSourceId);
|
||||
return getSourceForGroup(getSelectedSourceGroup(), state.selectedSourceId)
|
||||
|| findById(getSources(), state.selectedSourceId);
|
||||
}
|
||||
|
||||
function getSelectedWork(source = getSelectedSource()) {
|
||||
@@ -188,6 +320,114 @@
|
||||
return String(value || "").trim();
|
||||
}
|
||||
|
||||
function buildTranslationOptionLabel(source) {
|
||||
const metadata = getSourceMetadata(source);
|
||||
return normalizeTextValue(metadata.translator)
|
||||
|| normalizeTextValue(metadata.versionLabel || metadata.version)
|
||||
|| normalizeTextValue(source?.shortTitle)
|
||||
|| normalizeTextValue(source?.title)
|
||||
|| "Translation";
|
||||
}
|
||||
|
||||
function getSourceMetadata(source) {
|
||||
return source?.metadata && typeof source.metadata === "object" ? source.metadata : {};
|
||||
}
|
||||
|
||||
function includesNormalizedText(container, value) {
|
||||
const containerText = normalizeTextValue(container).toLowerCase();
|
||||
const valueText = normalizeTextValue(value).toLowerCase();
|
||||
return Boolean(containerText && valueText && containerText.includes(valueText));
|
||||
}
|
||||
|
||||
function formatCountLabel(count, label) {
|
||||
const normalizedCount = Number(count) || 0;
|
||||
const baseLabel = normalizeTextValue(label) || "item";
|
||||
if (normalizedCount === 1) {
|
||||
return `${normalizedCount} ${baseLabel}`;
|
||||
}
|
||||
return `${normalizedCount} ${baseLabel.endsWith("s") ? baseLabel : `${baseLabel}s`}`;
|
||||
}
|
||||
|
||||
function getSourceEditionLabel(source) {
|
||||
const metadata = getSourceMetadata(source);
|
||||
const version = normalizeTextValue(metadata.versionLabel || metadata.version);
|
||||
const translator = normalizeTextValue(metadata.translator);
|
||||
|
||||
if (
|
||||
version
|
||||
&& translator
|
||||
&& normalizeId(version) !== normalizeId(translator)
|
||||
&& !includesNormalizedText(version, translator)
|
||||
&& !includesNormalizedText(translator, version)
|
||||
) {
|
||||
return `${version} · ${translator}`;
|
||||
}
|
||||
|
||||
return version || translator;
|
||||
}
|
||||
|
||||
function buildSourceListMeta(source) {
|
||||
const shortTitle = normalizeTextValue(source?.shortTitle);
|
||||
const title = normalizeTextValue(source?.title);
|
||||
const editionLabel = getSourceEditionLabel(source);
|
||||
const parts = [];
|
||||
|
||||
if (shortTitle && normalizeId(shortTitle) !== normalizeId(title)) {
|
||||
parts.push(shortTitle);
|
||||
}
|
||||
|
||||
if (editionLabel && !parts.some((part) => includesNormalizedText(part, editionLabel) || includesNormalizedText(editionLabel, part))) {
|
||||
parts.push(editionLabel);
|
||||
}
|
||||
|
||||
parts.push(formatCountLabel(source?.stats?.workCount, source?.workLabel || "Work"));
|
||||
parts.push(formatCountLabel(source?.stats?.sectionCount, source?.sectionLabel || "Section"));
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
function buildSourceGroupListMeta(group) {
|
||||
const activeSource = getSourceForGroup(group);
|
||||
if (!group || getSourceVariants(group).length <= 1) {
|
||||
return buildSourceListMeta(activeSource);
|
||||
}
|
||||
|
||||
const translators = Array.from(new Set(
|
||||
getSourceVariants(group)
|
||||
.map((source) => normalizeTextValue(getSourceMetadata(source).translator))
|
||||
.filter(Boolean)
|
||||
));
|
||||
|
||||
const parts = [];
|
||||
if (translators.length) {
|
||||
parts.push(translators.join(" / "));
|
||||
}
|
||||
parts.push(formatCountLabel(getSourceVariants(group).length, "translation"));
|
||||
parts.push(formatCountLabel(activeSource?.stats?.sectionCount, activeSource?.sectionLabel || "Section"));
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
function buildSourceDetailSubtitle(source, work) {
|
||||
const parts = [normalizeTextValue(source?.title) || "--"];
|
||||
const editionLabel = getSourceEditionLabel(source);
|
||||
const workTitle = normalizeTextValue(work?.title);
|
||||
|
||||
if (editionLabel) {
|
||||
parts.push(editionLabel);
|
||||
}
|
||||
|
||||
if (workTitle && normalizeId(workTitle) !== normalizeId(source?.title)) {
|
||||
parts.push(workTitle);
|
||||
}
|
||||
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
function buildCompareCardTitle(passage) {
|
||||
const source = passage?.source || getSelectedSource();
|
||||
const section = passage?.section || getSelectedSection(source, getSelectedWork(source));
|
||||
return `${buildTranslationOptionLabel(source)} · ${section?.title || section?.label || "--"}`;
|
||||
}
|
||||
|
||||
function extractVerseCountText(verse, source, displayPreferences, translationText = "") {
|
||||
const mode = displayPreferences?.textMode || "translation";
|
||||
const originalText = normalizeTextValue(verse?.originalText);
|
||||
@@ -627,6 +867,28 @@
|
||||
}
|
||||
}
|
||||
|
||||
function syncSelectionForGroup(group = getSelectedSourceGroup()) {
|
||||
const variants = getSourceVariants(group);
|
||||
if (!variants.length) {
|
||||
state.selectedSourceGroupId = "";
|
||||
state.selectedSourceId = "";
|
||||
state.selectedWorkId = "";
|
||||
state.selectedSectionId = "";
|
||||
return;
|
||||
}
|
||||
|
||||
state.selectedSourceGroupId = group.id;
|
||||
const rememberedSourceId = state.selectedSourceIdByGroup[normalizeId(group.id)] || "";
|
||||
const source = findById(variants, state.selectedSourceId)
|
||||
|| findById(variants, rememberedSourceId)
|
||||
|| variants[0];
|
||||
|
||||
state.selectedSourceId = source?.id || "";
|
||||
rememberSelectedSource(group, state.selectedSourceId);
|
||||
syncSelectionForSource(source);
|
||||
syncCompareSelection(group);
|
||||
}
|
||||
|
||||
async function ensureCatalogLoaded(forceRefresh = false) {
|
||||
if (!forceRefresh && state.catalog) {
|
||||
return state.catalog;
|
||||
@@ -637,11 +899,17 @@
|
||||
? payload
|
||||
: { meta: {}, sources: [], lexicons: [] };
|
||||
|
||||
if (!state.selectedSourceId) {
|
||||
state.selectedSourceId = getSources()[0]?.id || "";
|
||||
state.catalog.sourceGroups = buildSourceGroups(getSources());
|
||||
|
||||
if (!state.selectedSourceGroupId && state.selectedSourceId) {
|
||||
state.selectedSourceGroupId = findSourceGroupBySourceId(state.selectedSourceId)?.id || "";
|
||||
}
|
||||
|
||||
syncSelectionForSource(getSelectedSource());
|
||||
if (!state.selectedSourceGroupId) {
|
||||
state.selectedSourceGroupId = getSourceGroups()[0]?.id || "";
|
||||
}
|
||||
|
||||
syncSelectionForGroup(getSelectedSourceGroup());
|
||||
return state.catalog;
|
||||
}
|
||||
|
||||
@@ -668,39 +936,39 @@
|
||||
}
|
||||
|
||||
sourceListEl.replaceChildren();
|
||||
const sources = getSources();
|
||||
sources.forEach((source) => {
|
||||
const sourceGroups = getSourceGroups();
|
||||
sourceGroups.forEach((group) => {
|
||||
const source = getSourceForGroup(group);
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "planet-list-item alpha-text-source-btn";
|
||||
button.dataset.sourceId = source.id;
|
||||
button.dataset.sourceGroupId = group.id;
|
||||
button.setAttribute("role", "option");
|
||||
|
||||
const isSelected = normalizeId(source.id) === normalizeId(state.selectedSourceId);
|
||||
const isSelected = normalizeId(group.id) === normalizeId(state.selectedSourceGroupId);
|
||||
button.classList.toggle("is-selected", isSelected);
|
||||
button.setAttribute("aria-selected", isSelected ? "true" : "false");
|
||||
|
||||
const name = document.createElement("span");
|
||||
name.className = "planet-list-name";
|
||||
name.textContent = source.title;
|
||||
name.textContent = group.title;
|
||||
|
||||
const meta = document.createElement("span");
|
||||
meta.className = "alpha-text-source-meta";
|
||||
const sectionLabel = source.sectionLabel || "Section";
|
||||
meta.textContent = `${source.shortTitle || source.title} · ${source.stats?.workCount || 0} ${source.workLabel || "Works"} · ${source.stats?.sectionCount || 0} ${sectionLabel.toLowerCase()}s`;
|
||||
meta.textContent = buildSourceGroupListMeta(group);
|
||||
|
||||
button.append(name, meta);
|
||||
button.addEventListener("click", () => {
|
||||
if (normalizeId(source.id) === normalizeId(state.selectedSourceId)) {
|
||||
if (normalizeId(group.id) === normalizeId(state.selectedSourceGroupId)) {
|
||||
showDetailOnlyMode();
|
||||
return;
|
||||
}
|
||||
|
||||
state.selectedSourceId = source.id;
|
||||
state.selectedSourceGroupId = group.id;
|
||||
state.currentPassage = null;
|
||||
state.lexiconEntry = null;
|
||||
state.highlightedVerseId = "";
|
||||
syncSelectionForSource(getSelectedSource());
|
||||
syncSelectionForGroup(group);
|
||||
renderSourceList();
|
||||
renderSelectors();
|
||||
showDetailOnlyMode();
|
||||
@@ -716,23 +984,56 @@
|
||||
sourceListEl.appendChild(button);
|
||||
});
|
||||
|
||||
if (!sources.length) {
|
||||
if (!sourceGroups.length) {
|
||||
sourceListEl.appendChild(createEmptyMessage("No text sources are available."));
|
||||
}
|
||||
|
||||
if (sourceCountEl) {
|
||||
sourceCountEl.textContent = `${sources.length} sources`;
|
||||
sourceCountEl.textContent = `${sourceGroups.length} sources`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderSelectors() {
|
||||
const group = getSelectedSourceGroup();
|
||||
const source = getSelectedSource();
|
||||
const work = getSelectedWork(source);
|
||||
const variants = getSourceVariants(group);
|
||||
const compareCandidates = getCompareCandidates(group);
|
||||
const compareSource = getCompareSource(group);
|
||||
const compareEnabled = isCompareModeEnabled(group);
|
||||
const works = Array.isArray(source?.works) ? source.works : [];
|
||||
const sections = Array.isArray(work?.sections) ? work.sections : [];
|
||||
|
||||
fillSelect(translationSelectEl, variants, state.selectedSourceId, (entry) => buildTranslationOptionLabel(entry));
|
||||
fillSelect(compareSelectEl, compareCandidates, compareSource?.id || "", (entry) => buildTranslationOptionLabel(entry));
|
||||
fillSelect(workSelectEl, works, state.selectedWorkId, (entry) => `${entry.title} (${entry.sectionCount} ${String(source?.sectionLabel || "section").toLowerCase()}s)`);
|
||||
fillSelect(sectionSelectEl, sections, state.selectedSectionId, (entry) => `${entry.label} · ${entry.verseCount} verses`);
|
||||
|
||||
if (translationSelectEl instanceof HTMLSelectElement) {
|
||||
translationSelectEl.disabled = variants.length <= 1;
|
||||
}
|
||||
|
||||
if (translationControlEl instanceof HTMLElement) {
|
||||
translationControlEl.hidden = variants.length <= 1;
|
||||
}
|
||||
|
||||
if (compareToggleEl instanceof HTMLButtonElement) {
|
||||
compareToggleEl.textContent = compareEnabled ? "On" : "Off";
|
||||
compareToggleEl.setAttribute("aria-pressed", compareEnabled ? "true" : "false");
|
||||
compareToggleEl.classList.toggle("is-selected", compareEnabled);
|
||||
}
|
||||
|
||||
if (compareToggleControlEl instanceof HTMLElement) {
|
||||
compareToggleControlEl.hidden = !isCompareAvailable(group);
|
||||
}
|
||||
|
||||
if (compareSelectEl instanceof HTMLSelectElement) {
|
||||
compareSelectEl.disabled = !compareEnabled || compareCandidates.length === 0;
|
||||
}
|
||||
|
||||
if (compareControlEl instanceof HTMLElement) {
|
||||
compareControlEl.hidden = !compareEnabled || compareCandidates.length === 0;
|
||||
}
|
||||
}
|
||||
|
||||
function closeLexiconEntry() {
|
||||
@@ -1044,9 +1345,14 @@
|
||||
}
|
||||
|
||||
function createMetaGrid(passage) {
|
||||
const sourceGroup = getSelectedSourceGroup();
|
||||
const source = passage?.source || getSelectedSource();
|
||||
const work = passage?.work || getSelectedWork(source);
|
||||
const section = passage?.section || getSelectedSection(source, work);
|
||||
const metadata = getSourceMetadata(source);
|
||||
const version = normalizeTextValue(metadata.versionLabel || metadata.version);
|
||||
const translator = normalizeTextValue(metadata.translator);
|
||||
const compareSource = getCompareSource(sourceGroup);
|
||||
const displayPreferences = getSourceDisplayPreferences(source, passage);
|
||||
const metaGrid = document.createElement("div");
|
||||
metaGrid.className = "alpha-text-meta-grid";
|
||||
@@ -1055,6 +1361,10 @@
|
||||
overviewCard.innerHTML += `
|
||||
<dl class="alpha-dl">
|
||||
<dt>Source</dt><dd>${source?.title || "--"}</dd>
|
||||
${version ? `<dt>Version</dt><dd>${version}</dd>` : ""}
|
||||
${translator ? `<dt>Translator</dt><dd>${translator}</dd>` : ""}
|
||||
${getSourceVariants(sourceGroup).length > 1 ? `<dt>Translations</dt><dd>${getSourceVariants(sourceGroup).map((entry) => buildTranslationOptionLabel(entry)).join(" / ")}</dd>` : ""}
|
||||
${isCompareModeEnabled(sourceGroup) && compareSource ? `<dt>Compare</dt><dd>${buildTranslationOptionLabel(compareSource)}</dd>` : ""}
|
||||
<dt>Tradition</dt><dd>${source?.tradition || "--"}</dd>
|
||||
<dt>Language</dt><dd>${source?.language || "--"}</dd>
|
||||
<dt>Script</dt><dd>${source?.script || "--"}</dd>
|
||||
@@ -1148,14 +1458,13 @@
|
||||
return metaGrid;
|
||||
}
|
||||
|
||||
function createPlainVerse(verse) {
|
||||
const source = getSelectedSource();
|
||||
const displayPreferences = getSourceDisplayPreferences(source, state.currentPassage);
|
||||
function createPlainVerse(verse, source, displayPreferences, options = {}) {
|
||||
const translationText = verse.text || "";
|
||||
const verseCounts = getTextCounts(extractVerseCountText(verse, source, displayPreferences, translationText));
|
||||
const isHighlighted = options.highlight !== false && isHighlightedVerse(verse);
|
||||
const article = document.createElement("article");
|
||||
article.className = "alpha-text-verse";
|
||||
article.classList.toggle("is-highlighted", isHighlightedVerse(verse));
|
||||
article.classList.toggle("is-highlighted", isHighlighted);
|
||||
|
||||
const head = document.createElement("div");
|
||||
head.className = "alpha-text-verse-head";
|
||||
@@ -1170,7 +1479,7 @@
|
||||
|
||||
head.append(reference, stats);
|
||||
article.append(head);
|
||||
appendVerseTextLines(article, verse, source, displayPreferences, translationText);
|
||||
appendVerseTextLines(article, verse, source, displayPreferences, translationText, isHighlighted ? state.searchQuery : "");
|
||||
return article;
|
||||
}
|
||||
|
||||
@@ -1185,7 +1494,7 @@
|
||||
return glossText || String(fallbackText || "").trim();
|
||||
}
|
||||
|
||||
function appendVerseTextLines(target, verse, source, displayPreferences, translationText) {
|
||||
function appendVerseTextLines(target, verse, source, displayPreferences, translationText, highlightQuery = "") {
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
@@ -1222,18 +1531,19 @@
|
||||
lines.forEach((line) => {
|
||||
const text = document.createElement("p");
|
||||
text.className = `alpha-text-verse-text alpha-text-verse-text--${line.variant}`;
|
||||
appendHighlightedText(text, line.text, isHighlightedVerse(verse) ? state.searchQuery : "");
|
||||
appendHighlightedText(text, line.text, highlightQuery);
|
||||
target.appendChild(text);
|
||||
});
|
||||
}
|
||||
|
||||
function createTokenVerse(verse, lexiconId, displayPreferences, source) {
|
||||
function createTokenVerse(verse, lexiconId, displayPreferences, source, options = {}) {
|
||||
const translationText = buildTokenTranslationText(verse?.tokens, verse?.text);
|
||||
const verseCounts = getTextCounts(extractVerseCountText(verse, source, displayPreferences, translationText));
|
||||
const isHighlighted = options.highlight !== false && isHighlightedVerse(verse);
|
||||
const article = document.createElement("article");
|
||||
article.className = "alpha-text-verse";
|
||||
article.classList.toggle("alpha-text-verse--interlinear", Boolean(displayPreferences?.showInterlinear));
|
||||
article.classList.toggle("is-highlighted", isHighlightedVerse(verse));
|
||||
article.classList.toggle("is-highlighted", isHighlighted);
|
||||
|
||||
const head = document.createElement("div");
|
||||
head.className = "alpha-text-verse-head";
|
||||
@@ -1282,43 +1592,14 @@
|
||||
|
||||
head.append(reference, stats);
|
||||
article.append(head);
|
||||
appendVerseTextLines(article, verse, source, displayPreferences, translationText);
|
||||
appendVerseTextLines(article, verse, source, displayPreferences, translationText, isHighlighted ? state.searchQuery : "");
|
||||
if (displayPreferences?.showInterlinear) {
|
||||
article.appendChild(tokenGrid);
|
||||
}
|
||||
return article;
|
||||
}
|
||||
|
||||
function createReaderCard(passage) {
|
||||
const source = passage?.source || getSelectedSource();
|
||||
const displayPreferences = getSourceDisplayPreferences(source, passage);
|
||||
const card = createCard(getPassageLocationLabel(passage));
|
||||
card.classList.add("alpha-text-reader-card");
|
||||
const reader = document.createElement("div");
|
||||
reader.className = "alpha-text-reader";
|
||||
|
||||
if (passage?.errorMessage) {
|
||||
reader.appendChild(createEmptyMessage(passage.errorMessage));
|
||||
card.appendChild(reader);
|
||||
return card;
|
||||
}
|
||||
|
||||
const verses = Array.isArray(passage?.verses) ? passage.verses : [];
|
||||
if (!verses.length) {
|
||||
reader.appendChild(createEmptyMessage("No verses were found for this section."));
|
||||
card.appendChild(reader);
|
||||
return card;
|
||||
}
|
||||
|
||||
verses.forEach((verse) => {
|
||||
const verseEl = source?.features?.hasTokenAnnotations
|
||||
? createTokenVerse(verse, source.features.lexiconIds?.[0] || "", displayPreferences, source)
|
||||
: createPlainVerse(verse);
|
||||
reader.appendChild(verseEl);
|
||||
});
|
||||
|
||||
card.appendChild(reader);
|
||||
|
||||
function createReaderNavigation(passage) {
|
||||
const navigation = document.createElement("div");
|
||||
navigation.className = "alpha-text-reader-navigation";
|
||||
|
||||
@@ -1344,13 +1625,70 @@
|
||||
navigation.appendChild(nextButton);
|
||||
}
|
||||
|
||||
if (navigation.childElementCount) {
|
||||
return navigation.childElementCount ? navigation : null;
|
||||
}
|
||||
|
||||
function createReaderCard(passage, options = {}) {
|
||||
const source = passage?.source || getSelectedSource();
|
||||
const displayPreferences = getSourceDisplayPreferences(source, passage);
|
||||
const card = createCard(options.title || getPassageLocationLabel(passage));
|
||||
card.classList.add("alpha-text-reader-card");
|
||||
if (options.compare) {
|
||||
card.classList.add("alpha-text-reader-card--compare");
|
||||
}
|
||||
const reader = document.createElement("div");
|
||||
reader.className = "alpha-text-reader";
|
||||
|
||||
if (passage?.errorMessage) {
|
||||
reader.appendChild(createEmptyMessage(passage.errorMessage));
|
||||
card.appendChild(reader);
|
||||
return card;
|
||||
}
|
||||
|
||||
const verses = Array.isArray(passage?.verses) ? passage.verses : [];
|
||||
if (!verses.length) {
|
||||
reader.appendChild(createEmptyMessage("No verses were found for this section."));
|
||||
card.appendChild(reader);
|
||||
return card;
|
||||
}
|
||||
|
||||
verses.forEach((verse) => {
|
||||
const verseEl = source?.features?.hasTokenAnnotations
|
||||
? createTokenVerse(verse, source.features.lexiconIds?.[0] || "", displayPreferences, source, options)
|
||||
: createPlainVerse(verse, source, displayPreferences, options);
|
||||
reader.appendChild(verseEl);
|
||||
});
|
||||
|
||||
card.appendChild(reader);
|
||||
|
||||
const navigation = options.showNavigation === false ? null : createReaderNavigation(passage);
|
||||
if (navigation) {
|
||||
card.appendChild(navigation);
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
function createCompareReaderGrid(primaryPassage, comparePassage) {
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "alpha-text-reader-compare";
|
||||
wrapper.appendChild(createReaderCard(primaryPassage, {
|
||||
title: buildCompareCardTitle(primaryPassage),
|
||||
showNavigation: false
|
||||
}));
|
||||
|
||||
if (comparePassage) {
|
||||
wrapper.appendChild(createReaderCard(comparePassage, {
|
||||
title: buildCompareCardTitle(comparePassage),
|
||||
compare: true,
|
||||
highlight: false,
|
||||
showNavigation: false
|
||||
}));
|
||||
}
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function createSearchCard() {
|
||||
const hasSearchState = state.searchLoading || state.searchError || state.searchResults || state.searchQuery;
|
||||
if (!hasSearchState) {
|
||||
@@ -1443,6 +1781,7 @@
|
||||
const source = getSelectedSource();
|
||||
const work = getSelectedWork(source);
|
||||
const section = getSelectedSection(source, work);
|
||||
const compareEnabled = isCompareModeEnabled(getSelectedSourceGroup());
|
||||
const globalSearchOnlyMode = isGlobalSearchOnlyMode();
|
||||
setGlobalSearchHeadingMode(globalSearchOnlyMode);
|
||||
|
||||
@@ -1460,7 +1799,7 @@
|
||||
if (detailSubEl) {
|
||||
detailSubEl.textContent = globalSearchOnlyMode
|
||||
? "All text sources"
|
||||
: `${source.title} · ${work.title}`;
|
||||
: buildSourceDetailSubtitle(source, work);
|
||||
}
|
||||
if (!detailBodyEl) {
|
||||
return;
|
||||
@@ -1487,38 +1826,99 @@
|
||||
}
|
||||
|
||||
detailBodyEl.appendChild(createMetaGrid(state.currentPassage));
|
||||
detailBodyEl.appendChild(createReaderCard(state.currentPassage));
|
||||
|
||||
if (compareEnabled && state.comparePassage) {
|
||||
detailBodyEl.appendChild(createCompareReaderGrid(state.currentPassage, state.comparePassage));
|
||||
const compareNavigation = createReaderNavigation(state.currentPassage);
|
||||
if (compareNavigation) {
|
||||
detailBodyEl.appendChild(compareNavigation);
|
||||
}
|
||||
} else {
|
||||
detailBodyEl.appendChild(createReaderCard(state.currentPassage));
|
||||
}
|
||||
renderLexiconPopup();
|
||||
}
|
||||
|
||||
function getComparableWork(source, work) {
|
||||
const works = Array.isArray(source?.works) ? source.works : [];
|
||||
return findById(works, work?.id)
|
||||
|| works.find((entry) => normalizeId(entry?.title) === normalizeId(work?.title))
|
||||
|| works[0]
|
||||
|| null;
|
||||
}
|
||||
|
||||
function getComparableSection(work, section) {
|
||||
const sections = Array.isArray(work?.sections) ? work.sections : [];
|
||||
return findById(sections, section?.id)
|
||||
|| sections.find((entry) => Number(entry?.number || 0) === Number(section?.number || 0))
|
||||
|| sections.find((entry) => normalizeId(entry?.title) === normalizeId(section?.title))
|
||||
|| sections.find((entry) => normalizeId(entry?.label) === normalizeId(section?.label))
|
||||
|| sections[0]
|
||||
|| null;
|
||||
}
|
||||
|
||||
function buildPassageLoadError(source, work, section, message) {
|
||||
return {
|
||||
source,
|
||||
work,
|
||||
section,
|
||||
verses: [],
|
||||
errorMessage: message
|
||||
};
|
||||
}
|
||||
|
||||
async function loadComparablePassage(compareSource, currentWork, currentSection) {
|
||||
const compareWork = getComparableWork(compareSource, currentWork);
|
||||
const compareSection = getComparableSection(compareWork, currentSection);
|
||||
if (!compareWork || !compareSection) {
|
||||
return buildPassageLoadError(compareSource, compareWork, compareSection, "Unable to align this comparison section.");
|
||||
}
|
||||
|
||||
try {
|
||||
return await dataService.loadTextSection?.(compareSource.id, compareWork.id, compareSection.id);
|
||||
} catch (error) {
|
||||
return buildPassageLoadError(compareSource, compareWork, compareSection, error?.message || "Unable to load the comparison translation.");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSelectedPassage() {
|
||||
const source = getSelectedSource();
|
||||
const work = getSelectedWork(source);
|
||||
const section = getSelectedSection(source, work);
|
||||
const compareSource = isCompareModeEnabled(getSelectedSourceGroup()) ? getCompareSource() : null;
|
||||
if (!source || !work || !section) {
|
||||
state.currentPassage = null;
|
||||
state.comparePassage = null;
|
||||
renderDetail();
|
||||
return;
|
||||
}
|
||||
|
||||
state.currentPassage = null;
|
||||
state.comparePassage = null;
|
||||
renderDetail();
|
||||
|
||||
try {
|
||||
state.currentPassage = await dataService.loadTextSection?.(source.id, work.id, section.id);
|
||||
renderDetail();
|
||||
if (state.highlightedVerseId) {
|
||||
requestAnimationFrame(scrollHighlightedVerseIntoView);
|
||||
}
|
||||
} catch (error) {
|
||||
state.currentPassage = {
|
||||
source,
|
||||
work,
|
||||
section,
|
||||
verses: [],
|
||||
errorMessage: error?.message || "Unable to load this section."
|
||||
};
|
||||
renderDetail();
|
||||
const [primaryResult, compareResult] = await Promise.allSettled([
|
||||
dataService.loadTextSection?.(source.id, work.id, section.id),
|
||||
compareSource ? loadComparablePassage(compareSource, work, section) : Promise.resolve(null)
|
||||
]);
|
||||
|
||||
if (primaryResult.status === "fulfilled") {
|
||||
state.currentPassage = primaryResult.value;
|
||||
} else {
|
||||
state.currentPassage = buildPassageLoadError(source, work, section, primaryResult.reason?.message || "Unable to load this section.");
|
||||
}
|
||||
|
||||
if (compareResult.status === "fulfilled") {
|
||||
state.comparePassage = compareResult.value;
|
||||
} else if (compareSource) {
|
||||
const compareWork = getComparableWork(compareSource, work);
|
||||
const compareSection = getComparableSection(compareWork, section);
|
||||
state.comparePassage = buildPassageLoadError(compareSource, compareWork, compareSection, compareResult.reason?.message || "Unable to load the comparison translation.");
|
||||
}
|
||||
|
||||
renderDetail();
|
||||
if (state.highlightedVerseId) {
|
||||
requestAnimationFrame(scrollHighlightedVerseIntoView);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1586,7 +1986,10 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceGroup = findSourceGroupBySourceId(result.sourceId);
|
||||
state.selectedSourceGroupId = sourceGroup?.id || "";
|
||||
state.selectedSourceId = result.sourceId;
|
||||
rememberSelectedSource(sourceGroup, result.sourceId);
|
||||
state.selectedWorkId = result.workId;
|
||||
state.selectedSectionId = result.sectionId;
|
||||
state.highlightedVerseId = result.verseId;
|
||||
@@ -1641,12 +2044,58 @@
|
||||
});
|
||||
}
|
||||
|
||||
if (translationSelectEl instanceof HTMLSelectElement) {
|
||||
translationSelectEl.addEventListener("change", () => {
|
||||
const sourceGroup = getSelectedSourceGroup();
|
||||
state.selectedSourceId = String(translationSelectEl.value || "");
|
||||
rememberSelectedSource(sourceGroup, state.selectedSourceId);
|
||||
syncSelectionForSource(getSelectedSource());
|
||||
state.currentPassage = null;
|
||||
state.comparePassage = null;
|
||||
state.lexiconEntry = null;
|
||||
state.highlightedVerseId = "";
|
||||
syncCompareSelection(sourceGroup);
|
||||
renderSourceList();
|
||||
renderSelectors();
|
||||
showDetailOnlyMode();
|
||||
|
||||
if (state.searchQuery && state.activeSearchScope === "source") {
|
||||
void Promise.all([loadSelectedPassage(), runSearch("source")]);
|
||||
return;
|
||||
}
|
||||
|
||||
void loadSelectedPassage();
|
||||
});
|
||||
}
|
||||
|
||||
if (compareToggleEl instanceof HTMLButtonElement) {
|
||||
compareToggleEl.addEventListener("click", () => {
|
||||
const sourceGroup = getSelectedSourceGroup();
|
||||
setCompareModeEnabled(sourceGroup, !isCompareModeEnabled(sourceGroup));
|
||||
syncCompareSelection(sourceGroup);
|
||||
state.comparePassage = null;
|
||||
renderSelectors();
|
||||
void loadSelectedPassage();
|
||||
});
|
||||
}
|
||||
|
||||
if (compareSelectEl instanceof HTMLSelectElement) {
|
||||
compareSelectEl.addEventListener("change", () => {
|
||||
const sourceGroup = getSelectedSourceGroup();
|
||||
rememberCompareSource(sourceGroup, String(compareSelectEl.value || ""));
|
||||
state.comparePassage = null;
|
||||
renderSelectors();
|
||||
void loadSelectedPassage();
|
||||
});
|
||||
}
|
||||
|
||||
if (workSelectEl) {
|
||||
workSelectEl.addEventListener("change", () => {
|
||||
state.selectedWorkId = String(workSelectEl.value || "");
|
||||
const source = getSelectedSource();
|
||||
syncSelectionForSource(source);
|
||||
state.currentPassage = null;
|
||||
state.comparePassage = null;
|
||||
state.lexiconEntry = null;
|
||||
state.highlightedVerseId = "";
|
||||
renderSelectors();
|
||||
@@ -1658,6 +2107,7 @@
|
||||
sectionSelectEl.addEventListener("change", () => {
|
||||
state.selectedSectionId = String(sectionSelectEl.value || "");
|
||||
state.currentPassage = null;
|
||||
state.comparePassage = null;
|
||||
state.lexiconEntry = null;
|
||||
state.highlightedVerseId = "";
|
||||
void loadSelectedPassage();
|
||||
@@ -1699,8 +2149,13 @@
|
||||
function resetState() {
|
||||
state.catalog = null;
|
||||
state.currentPassage = null;
|
||||
state.comparePassage = null;
|
||||
state.lexiconEntry = null;
|
||||
state.selectedSourceGroupId = "";
|
||||
state.selectedSourceId = "";
|
||||
state.selectedSourceIdByGroup = {};
|
||||
state.compareSourceIdByGroup = {};
|
||||
state.compareModeByGroup = {};
|
||||
state.selectedWorkId = "";
|
||||
state.selectedSectionId = "";
|
||||
state.lexiconRequestId = 0;
|
||||
|
||||
408
app/ui-audio-circle.js
Normal file
408
app/ui-audio-circle.js
Normal file
@@ -0,0 +1,408 @@
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
const CIRCLE_ENTRIES = [
|
||||
{ id: "c", major: "C", relativeMinor: "Am", pitchClass: 0, frequencyLabel: "C4" },
|
||||
{ id: "g", major: "G", relativeMinor: "Em", pitchClass: 7, frequencyLabel: "G4" },
|
||||
{ id: "d", major: "D", relativeMinor: "Bm", pitchClass: 2, frequencyLabel: "D4" },
|
||||
{ id: "a", major: "A", relativeMinor: "F#m", pitchClass: 9, frequencyLabel: "A4" },
|
||||
{ id: "e", major: "E", relativeMinor: "C#m", pitchClass: 4, frequencyLabel: "E4" },
|
||||
{ id: "b", major: "B", relativeMinor: "G#m", pitchClass: 11, frequencyLabel: "B4" },
|
||||
{ id: "f-sharp-g-flat", major: "F#/Gb", relativeMinor: "D#m/Ebm", pitchClass: 6, frequencyLabel: "F#/Gb4" },
|
||||
{ id: "d-flat", major: "Db", relativeMinor: "Bbm", pitchClass: 1, frequencyLabel: "Db4" },
|
||||
{ id: "a-flat", major: "Ab", relativeMinor: "Fm", pitchClass: 8, frequencyLabel: "Ab4" },
|
||||
{ id: "e-flat", major: "Eb", relativeMinor: "Cm", pitchClass: 3, frequencyLabel: "Eb4" },
|
||||
{ id: "b-flat", major: "Bb", relativeMinor: "Gm", pitchClass: 10, frequencyLabel: "Bb4" },
|
||||
{ id: "f", major: "F", relativeMinor: "Dm", pitchClass: 5, frequencyLabel: "F4" }
|
||||
];
|
||||
|
||||
const state = {
|
||||
initialized: false,
|
||||
selectedId: "c",
|
||||
audioContext: null,
|
||||
oscillatorNode: null,
|
||||
gainNode: null,
|
||||
stopTimerId: 0,
|
||||
activePlaybackKey: ""
|
||||
};
|
||||
|
||||
function getElements() {
|
||||
return {
|
||||
stageEl: document.getElementById("audio-circle-stage"),
|
||||
detailNameEl: document.getElementById("audio-circle-detail-name"),
|
||||
detailSubEl: document.getElementById("audio-circle-detail-sub"),
|
||||
detailSummaryEl: document.getElementById("audio-circle-detail-summary"),
|
||||
detailBodyEl: document.getElementById("audio-circle-detail-body")
|
||||
};
|
||||
}
|
||||
|
||||
function getMidiNumber(pitchClass, octave) {
|
||||
return ((octave + 1) * 12) + pitchClass;
|
||||
}
|
||||
|
||||
function getFrequencyHz(pitchClass, octave) {
|
||||
const midiNumber = getMidiNumber(pitchClass, octave);
|
||||
return 440 * Math.pow(2, (midiNumber - 69) / 12);
|
||||
}
|
||||
|
||||
function formatFrequencyHz(value) {
|
||||
return `${value.toFixed(2)} Hz`;
|
||||
}
|
||||
|
||||
function findEntryById(id) {
|
||||
return CIRCLE_ENTRIES.find((entry) => entry.id === id) || null;
|
||||
}
|
||||
|
||||
function getSelectedEntry() {
|
||||
return findEntryById(state.selectedId) || CIRCLE_ENTRIES[0];
|
||||
}
|
||||
|
||||
function getAudioContext() {
|
||||
if (state.audioContext) {
|
||||
return state.audioContext;
|
||||
}
|
||||
|
||||
const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
|
||||
if (typeof AudioContextCtor !== "function") {
|
||||
return null;
|
||||
}
|
||||
|
||||
state.audioContext = new AudioContextCtor();
|
||||
return state.audioContext;
|
||||
}
|
||||
|
||||
function clearStopTimer() {
|
||||
if (state.stopTimerId) {
|
||||
window.clearTimeout(state.stopTimerId);
|
||||
state.stopTimerId = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function clearActivePlayback(shouldRender = true) {
|
||||
state.oscillatorNode = null;
|
||||
state.gainNode = null;
|
||||
state.activePlaybackKey = "";
|
||||
clearStopTimer();
|
||||
if (shouldRender) {
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
function stopPlayback(shouldRender = true) {
|
||||
clearStopTimer();
|
||||
|
||||
const oscillatorNode = state.oscillatorNode;
|
||||
const gainNode = state.gainNode;
|
||||
const audioContext = state.audioContext;
|
||||
|
||||
state.oscillatorNode = null;
|
||||
state.gainNode = null;
|
||||
state.activePlaybackKey = "";
|
||||
|
||||
if (oscillatorNode && gainNode && audioContext) {
|
||||
const now = audioContext.currentTime;
|
||||
gainNode.gain.cancelScheduledValues(now);
|
||||
gainNode.gain.setValueAtTime(Math.max(gainNode.gain.value, 0.0001), now);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.0001, now + 0.06);
|
||||
try {
|
||||
oscillatorNode.stop(now + 0.07);
|
||||
} catch (_error) {
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldRender) {
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
async function playFrequency(frequencyHz, playbackKey) {
|
||||
const audioContext = getAudioContext();
|
||||
if (!audioContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.activePlaybackKey === playbackKey) {
|
||||
stopPlayback(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (audioContext.state === "suspended") {
|
||||
await audioContext.resume();
|
||||
}
|
||||
|
||||
stopPlayback(false);
|
||||
|
||||
const oscillatorNode = audioContext.createOscillator();
|
||||
const gainNode = audioContext.createGain();
|
||||
const now = audioContext.currentTime;
|
||||
|
||||
oscillatorNode.type = "sine";
|
||||
oscillatorNode.frequency.setValueAtTime(frequencyHz, now);
|
||||
|
||||
gainNode.gain.setValueAtTime(0.0001, now);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.16, now + 0.03);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.0001, now + 1.1);
|
||||
|
||||
oscillatorNode.connect(gainNode);
|
||||
gainNode.connect(audioContext.destination);
|
||||
|
||||
state.oscillatorNode = oscillatorNode;
|
||||
state.gainNode = gainNode;
|
||||
state.activePlaybackKey = playbackKey;
|
||||
|
||||
oscillatorNode.onended = () => {
|
||||
if (state.oscillatorNode === oscillatorNode || state.activePlaybackKey === playbackKey) {
|
||||
clearActivePlayback(true);
|
||||
}
|
||||
};
|
||||
|
||||
oscillatorNode.start(now);
|
||||
oscillatorNode.stop(now + 1.12);
|
||||
state.stopTimerId = window.setTimeout(() => {
|
||||
clearActivePlayback(true);
|
||||
}, 1250);
|
||||
|
||||
render();
|
||||
}
|
||||
|
||||
function createMetaCard(title) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "planet-meta-card";
|
||||
const heading = document.createElement("strong");
|
||||
heading.textContent = title;
|
||||
card.appendChild(heading);
|
||||
return card;
|
||||
}
|
||||
|
||||
function renderCircleStage(elements) {
|
||||
if (!elements.stageEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shell = document.createElement("div");
|
||||
shell.className = "audio-circle-shell";
|
||||
|
||||
const center = document.createElement("div");
|
||||
center.className = "audio-circle-center";
|
||||
const selectedEntry = getSelectedEntry();
|
||||
center.innerHTML = `
|
||||
<div>
|
||||
<div class="audio-circle-center-label">${selectedEntry.major}</div>
|
||||
<div class="audio-circle-center-sub">Relative minor ${selectedEntry.relativeMinor}<br>Circle of Fifths</div>
|
||||
</div>
|
||||
`;
|
||||
shell.appendChild(center);
|
||||
|
||||
const radius = 41;
|
||||
CIRCLE_ENTRIES.forEach((entry, index) => {
|
||||
const angleDegrees = -90 + (index * 30);
|
||||
const angleRadians = (angleDegrees * Math.PI) / 180;
|
||||
const x = 50 + (Math.cos(angleRadians) * radius);
|
||||
const y = 50 + (Math.sin(angleRadians) * radius);
|
||||
const playbackKey = `circle:${entry.id}`;
|
||||
const isSelected = entry.id === state.selectedId;
|
||||
const isPlaying = state.activePlaybackKey === playbackKey;
|
||||
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = `audio-circle-key${isSelected || isPlaying ? " is-selected" : ""}`;
|
||||
button.dataset.entryId = entry.id;
|
||||
button.dataset.action = "play-entry";
|
||||
button.dataset.playbackKey = playbackKey;
|
||||
button.dataset.frequencyHz = String(getFrequencyHz(entry.pitchClass, 4));
|
||||
button.style.left = `${x}%`;
|
||||
button.style.top = `${y}%`;
|
||||
button.style.transform = "translate(-50%, -50%)";
|
||||
button.setAttribute("aria-pressed", isPlaying ? "true" : "false");
|
||||
button.setAttribute("aria-label", `${isPlaying ? "Stop" : "Play"} ${entry.major}`);
|
||||
|
||||
const major = document.createElement("div");
|
||||
major.className = "audio-circle-key-major";
|
||||
major.textContent = entry.major;
|
||||
|
||||
const minor = document.createElement("div");
|
||||
minor.className = "audio-circle-key-minor";
|
||||
minor.textContent = entry.relativeMinor;
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "audio-circle-key-meta";
|
||||
meta.textContent = isPlaying ? "Playing" : "Tap";
|
||||
|
||||
button.append(major, minor, meta);
|
||||
shell.appendChild(button);
|
||||
});
|
||||
|
||||
elements.stageEl.replaceChildren(shell);
|
||||
}
|
||||
|
||||
function renderDetail(elements) {
|
||||
const entry = getSelectedEntry();
|
||||
if (!elements.detailNameEl || !elements.detailSubEl || !elements.detailSummaryEl || !elements.detailBodyEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entryIndex = CIRCLE_ENTRIES.findIndex((item) => item.id === entry.id);
|
||||
const clockwiseEntry = CIRCLE_ENTRIES[(entryIndex + 1) % CIRCLE_ENTRIES.length];
|
||||
const counterClockwiseEntry = CIRCLE_ENTRIES[(entryIndex - 1 + CIRCLE_ENTRIES.length) % CIRCLE_ENTRIES.length];
|
||||
const frequencyHz = getFrequencyHz(entry.pitchClass, 4);
|
||||
|
||||
elements.detailNameEl.textContent = `${entry.major} Major`;
|
||||
elements.detailSubEl.textContent = `${entry.relativeMinor} relative minor · tonic ${entry.frequencyLabel}`;
|
||||
elements.detailSummaryEl.textContent = `${entry.major} sits between ${counterClockwiseEntry.major} and ${clockwiseEntry.major} on the Circle of Fifths.`;
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "planet-meta-grid";
|
||||
|
||||
const overviewCard = createMetaCard("Key Overview");
|
||||
overviewCard.innerHTML += `
|
||||
<dl class="alpha-dl">
|
||||
<dt>Major key</dt><dd>${entry.major}</dd>
|
||||
<dt>Relative minor</dt><dd>${entry.relativeMinor}</dd>
|
||||
<dt>Tonic pitch</dt><dd>${entry.frequencyLabel}</dd>
|
||||
<dt>Tonic frequency</dt><dd>${formatFrequencyHz(frequencyHz)}</dd>
|
||||
<dt>Clockwise neighbor</dt><dd>${clockwiseEntry.major}</dd>
|
||||
<dt>Counter-clockwise neighbor</dt><dd>${counterClockwiseEntry.major}</dd>
|
||||
</dl>
|
||||
`;
|
||||
|
||||
const chipRow = document.createElement("div");
|
||||
chipRow.className = "audio-circle-chip-row";
|
||||
[entry.major, entry.relativeMinor, clockwiseEntry.major, counterClockwiseEntry.major].forEach((label, index) => {
|
||||
const chip = document.createElement("span");
|
||||
chip.className = `audio-circle-chip${index === 0 ? " audio-circle-chip--active" : ""}`;
|
||||
chip.textContent = label;
|
||||
chipRow.appendChild(chip);
|
||||
});
|
||||
overviewCard.appendChild(chipRow);
|
||||
|
||||
const explanationCard = createMetaCard("How To Read The Circle");
|
||||
const explanation = document.createElement("div");
|
||||
explanation.className = "audio-circle-copy";
|
||||
explanation.textContent = "Moving clockwise adds one sharp and raises the tonic by a perfect fifth. Moving counter-clockwise adds one flat and returns by a perfect fourth. The inner minor labels show the relative minor that shares the same key signature.";
|
||||
explanationCard.appendChild(explanation);
|
||||
|
||||
const playbackCard = createMetaCard("Tone Preview");
|
||||
const playbackCopy = document.createElement("div");
|
||||
playbackCopy.className = "audio-circle-copy";
|
||||
playbackCopy.textContent = `Tap any key on the circle to hear its tonic. ${entry.major} is currently centered at ${formatFrequencyHz(frequencyHz)}.`;
|
||||
const playbackActions = document.createElement("div");
|
||||
playbackActions.className = "audio-note-playback-actions";
|
||||
|
||||
const playButton = document.createElement("button");
|
||||
playButton.type = "button";
|
||||
playButton.className = "alpha-nav-btn audio-note-playback-btn";
|
||||
playButton.dataset.action = "play-entry";
|
||||
playButton.dataset.entryId = entry.id;
|
||||
playButton.dataset.playbackKey = `circle:${entry.id}`;
|
||||
playButton.dataset.frequencyHz = String(frequencyHz);
|
||||
playButton.textContent = `Play ${entry.major}`;
|
||||
|
||||
const stopButton = document.createElement("button");
|
||||
stopButton.type = "button";
|
||||
stopButton.className = "alpha-nav-btn alpha-nav-btn--ghost audio-note-playback-btn";
|
||||
stopButton.dataset.action = "stop-playback";
|
||||
stopButton.textContent = "Stop";
|
||||
|
||||
playbackActions.append(playButton, stopButton);
|
||||
playbackCard.append(playbackCopy, playbackActions);
|
||||
|
||||
const navCard = createMetaCard("Neighbors");
|
||||
const navGrid = document.createElement("div");
|
||||
navGrid.className = "audio-circle-nav-grid";
|
||||
[
|
||||
{ label: "Counter-clockwise", entry: counterClockwiseEntry },
|
||||
{ label: "Clockwise", entry: clockwiseEntry }
|
||||
].forEach(({ label, entry: neighbor }) => {
|
||||
const card = document.createElement("div");
|
||||
card.className = "audio-circle-nav-card";
|
||||
card.innerHTML = `
|
||||
<div class="audio-circle-nav-label">${label}</div>
|
||||
<div class="audio-circle-nav-value">${neighbor.major}</div>
|
||||
<div class="audio-circle-nav-sub">Relative minor ${neighbor.relativeMinor}</div>
|
||||
`;
|
||||
navGrid.appendChild(card);
|
||||
});
|
||||
navCard.appendChild(navGrid);
|
||||
|
||||
grid.append(overviewCard, explanationCard, playbackCard, navCard);
|
||||
elements.detailBodyEl.replaceChildren(grid);
|
||||
}
|
||||
|
||||
function render() {
|
||||
const elements = getElements();
|
||||
renderCircleStage(elements);
|
||||
renderDetail(elements);
|
||||
}
|
||||
|
||||
function selectEntry(id, shouldRender = true) {
|
||||
if (!findEntryById(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.selectedId = id;
|
||||
if (shouldRender) {
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
const elements = getElements();
|
||||
if (!elements.stageEl || !elements.detailBodyEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clickHandler = (event) => {
|
||||
const button = event.target instanceof Element ? event.target.closest("button[data-action]") : null;
|
||||
if (!(button instanceof HTMLButtonElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const action = button.dataset.action || "";
|
||||
if (action === "stop-playback") {
|
||||
stopPlayback(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "play-entry") {
|
||||
const entryId = String(button.dataset.entryId || "").trim();
|
||||
const playbackKey = String(button.dataset.playbackKey || "").trim();
|
||||
const frequencyHz = Number(button.dataset.frequencyHz);
|
||||
if (!entryId || !playbackKey || !Number.isFinite(frequencyHz)) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectEntry(entryId, false);
|
||||
void playFrequency(frequencyHz, playbackKey);
|
||||
}
|
||||
};
|
||||
|
||||
elements.stageEl.addEventListener("click", clickHandler);
|
||||
elements.detailBodyEl.addEventListener("click", clickHandler);
|
||||
|
||||
window.addEventListener("beforeunload", () => {
|
||||
stopPlayback(false);
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
if (state.initialized) {
|
||||
render();
|
||||
return;
|
||||
}
|
||||
|
||||
bindEvents();
|
||||
render();
|
||||
state.initialized = true;
|
||||
}
|
||||
|
||||
function ensureAudioCircleSection() {
|
||||
init();
|
||||
}
|
||||
|
||||
window.AudioCircleUi = {
|
||||
...(window.AudioCircleUi || {}),
|
||||
init,
|
||||
ensureAudioCircleSection,
|
||||
selectEntry
|
||||
};
|
||||
})();
|
||||
671
app/ui-audio-notes.js
Normal file
671
app/ui-audio-notes.js
Normal file
@@ -0,0 +1,671 @@
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
const OCTAVES = [0, 1, 2, 3, 4, 5, 6, 7, 8];
|
||||
const SPEED_OF_SOUND_METERS_PER_SECOND = 343;
|
||||
const INTERVALS_FROM_C = [
|
||||
"Unison",
|
||||
"Minor second",
|
||||
"Major second",
|
||||
"Minor third",
|
||||
"Major third",
|
||||
"Perfect fourth",
|
||||
"Tritone",
|
||||
"Perfect fifth",
|
||||
"Minor sixth",
|
||||
"Major sixth",
|
||||
"Minor seventh",
|
||||
"Major seventh"
|
||||
];
|
||||
|
||||
const NOTE_DEFINITIONS = [
|
||||
{
|
||||
id: "c",
|
||||
label: "C",
|
||||
pitchClass: 0,
|
||||
family: "Natural",
|
||||
keyColor: "White key",
|
||||
aliases: ["Do"],
|
||||
description: "C is the anchor note for scientific pitch notation, and middle C is the common visual landmark many players use to orient the keyboard.",
|
||||
usage: "In basic theory it often feels like a starting point because C major uses only natural notes."
|
||||
},
|
||||
{
|
||||
id: "c-sharp-d-flat",
|
||||
label: "C#/Db",
|
||||
pitchClass: 1,
|
||||
family: "Accidental",
|
||||
keyColor: "Black key",
|
||||
aliases: ["C sharp", "D flat"],
|
||||
description: "C sharp and D flat are the same sounding pitch in equal temperament, sitting one semitone above C and one semitone below D.",
|
||||
usage: "It is a common chromatic color tone that adds immediate tension or forward motion."
|
||||
},
|
||||
{
|
||||
id: "d",
|
||||
label: "D",
|
||||
pitchClass: 2,
|
||||
family: "Natural",
|
||||
keyColor: "White key",
|
||||
aliases: ["Re"],
|
||||
description: "D is a whole step above C and often acts like a clear, open support tone in many scales and melodies.",
|
||||
usage: "In C major it functions as the supertonic, which often points onward rather than feeling final."
|
||||
},
|
||||
{
|
||||
id: "d-sharp-e-flat",
|
||||
label: "D#/Eb",
|
||||
pitchClass: 3,
|
||||
family: "Accidental",
|
||||
keyColor: "Black key",
|
||||
aliases: ["D sharp", "E flat"],
|
||||
description: "D sharp and E flat are enharmonic spellings of the same pitch class, positioned three semitones above C.",
|
||||
usage: "It is common in minor colors and in flat-oriented keys such as E-flat major."
|
||||
},
|
||||
{
|
||||
id: "e",
|
||||
label: "E",
|
||||
pitchClass: 4,
|
||||
family: "Natural",
|
||||
keyColor: "White key",
|
||||
aliases: ["Mi"],
|
||||
description: "E is the major third above C, so it strongly helps define the sound of major harmony.",
|
||||
usage: "When E is present over C, the chord immediately feels brighter and more settled."
|
||||
},
|
||||
{
|
||||
id: "f",
|
||||
label: "F",
|
||||
pitchClass: 5,
|
||||
family: "Natural",
|
||||
keyColor: "White key",
|
||||
aliases: ["Fa"],
|
||||
description: "F is the perfect fourth above C and often feels stable while still wanting to move or resolve in tonal music.",
|
||||
usage: "It frequently appears as a structural tone in melodies and as the root of flat-side key centers."
|
||||
},
|
||||
{
|
||||
id: "f-sharp-g-flat",
|
||||
label: "F#/Gb",
|
||||
pitchClass: 6,
|
||||
family: "Accidental",
|
||||
keyColor: "Black key",
|
||||
aliases: ["F sharp", "G flat"],
|
||||
description: "F sharp and G flat form the tritone above C, the octave's most evenly divided and tense pitch relationship.",
|
||||
usage: "Because it sits at the midpoint of the octave, it is often used to create instability or dramatic pull."
|
||||
},
|
||||
{
|
||||
id: "g",
|
||||
label: "G",
|
||||
pitchClass: 7,
|
||||
family: "Natural",
|
||||
keyColor: "White key",
|
||||
aliases: ["Sol"],
|
||||
description: "G is the perfect fifth above C and one of the most stable interval relationships in Western tonal music.",
|
||||
usage: "It reinforces harmony strongly and often feels supportive, open, and structurally important."
|
||||
},
|
||||
{
|
||||
id: "g-sharp-a-flat",
|
||||
label: "G#/Ab",
|
||||
pitchClass: 8,
|
||||
family: "Accidental",
|
||||
keyColor: "Black key",
|
||||
aliases: ["G sharp", "A flat"],
|
||||
description: "G sharp and A flat are enharmonic spellings used in different key contexts for the same sounding pitch.",
|
||||
usage: "This pitch often acts as a vivid chromatic color that leans upward toward A or downward toward G."
|
||||
},
|
||||
{
|
||||
id: "a",
|
||||
label: "A",
|
||||
pitchClass: 9,
|
||||
family: "Natural",
|
||||
keyColor: "White key",
|
||||
aliases: ["La"],
|
||||
description: "A is the modern concert-pitch reference note, with A4 standardized at 440 Hz for most contemporary instruments and tuning devices.",
|
||||
usage: "It is the main calibration point for ensembles, tuners, and synthesized reference tones."
|
||||
},
|
||||
{
|
||||
id: "a-sharp-b-flat",
|
||||
label: "A#/Bb",
|
||||
pitchClass: 10,
|
||||
family: "Accidental",
|
||||
keyColor: "Black key",
|
||||
aliases: ["A sharp", "B flat"],
|
||||
description: "A sharp and B flat are the same equal-tempered pitch, frequently spelled as B flat in ensemble and band-oriented music.",
|
||||
usage: "It shows up often in brass, wind, and flat-key writing, where B-flat-centered harmony is common."
|
||||
},
|
||||
{
|
||||
id: "b",
|
||||
label: "B",
|
||||
pitchClass: 11,
|
||||
family: "Natural",
|
||||
keyColor: "White key",
|
||||
aliases: ["Ti"],
|
||||
description: "B is the major seventh above C, so it often behaves like a leading tone that wants to resolve upward.",
|
||||
usage: "That strong pull makes it useful for creating expectation and directional movement toward C."
|
||||
}
|
||||
];
|
||||
|
||||
const state = {
|
||||
initialized: false,
|
||||
entries: [],
|
||||
filteredEntries: [],
|
||||
selectedId: "",
|
||||
searchQuery: "",
|
||||
audioContext: null,
|
||||
oscillatorNode: null,
|
||||
gainNode: null,
|
||||
stopTimerId: 0,
|
||||
activePlaybackKey: ""
|
||||
};
|
||||
|
||||
function getElements() {
|
||||
return {
|
||||
countEl: document.getElementById("audio-note-count"),
|
||||
listEl: document.getElementById("audio-note-list"),
|
||||
searchEl: document.getElementById("audio-note-search-input"),
|
||||
searchClearEl: document.getElementById("audio-note-search-clear"),
|
||||
detailNameEl: document.getElementById("audio-note-detail-name"),
|
||||
detailSubEl: document.getElementById("audio-note-detail-sub"),
|
||||
detailSummaryEl: document.getElementById("audio-note-detail-summary"),
|
||||
detailBodyEl: document.getElementById("audio-note-detail-body")
|
||||
};
|
||||
}
|
||||
|
||||
function normalize(value) {
|
||||
return String(value || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9#]+/g, " ")
|
||||
.replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
function getMidiNumber(pitchClass, octave) {
|
||||
return ((octave + 1) * 12) + pitchClass;
|
||||
}
|
||||
|
||||
function getFrequencyHz(pitchClass, octave) {
|
||||
const midiNumber = getMidiNumber(pitchClass, octave);
|
||||
return 440 * Math.pow(2, (midiNumber - 69) / 12);
|
||||
}
|
||||
|
||||
function formatFrequencyHz(value) {
|
||||
if (value >= 100) {
|
||||
return `${value.toFixed(2)} Hz`;
|
||||
}
|
||||
if (value >= 10) {
|
||||
return `${value.toFixed(3)} Hz`;
|
||||
}
|
||||
return `${value.toFixed(4)} Hz`;
|
||||
}
|
||||
|
||||
function formatMeters(value) {
|
||||
if (value >= 1) {
|
||||
return `${value.toFixed(2)} m`;
|
||||
}
|
||||
return `${value.toFixed(3)} m`;
|
||||
}
|
||||
|
||||
function formatMilliseconds(value) {
|
||||
return `${value.toFixed(2)} ms`;
|
||||
}
|
||||
|
||||
function formatSignedSemitoneOffset(value) {
|
||||
if (value === 0) {
|
||||
return "Concert pitch reference";
|
||||
}
|
||||
|
||||
const distance = Math.abs(value);
|
||||
const direction = value > 0 ? "above" : "below";
|
||||
return `${distance} semitone${distance === 1 ? "" : "s"} ${direction} A4`;
|
||||
}
|
||||
|
||||
function buildEntries() {
|
||||
return NOTE_DEFINITIONS.map((definition) => {
|
||||
const referenceOctave = 4;
|
||||
const referenceFrequencyHz = getFrequencyHz(definition.pitchClass, referenceOctave);
|
||||
const referenceMidiNumber = getMidiNumber(definition.pitchClass, referenceOctave);
|
||||
const octaveSeries = OCTAVES.map((octave) => {
|
||||
const frequencyHz = getFrequencyHz(definition.pitchClass, octave);
|
||||
return {
|
||||
octave,
|
||||
label: `${definition.label}${octave}`,
|
||||
frequencyHz,
|
||||
midiNumber: getMidiNumber(definition.pitchClass, octave)
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...definition,
|
||||
intervalFromC: INTERVALS_FROM_C[definition.pitchClass],
|
||||
referenceOctave,
|
||||
referenceLabel: `${definition.label}${referenceOctave}`,
|
||||
referenceFrequencyHz,
|
||||
referenceMidiNumber,
|
||||
wavelengthMeters: SPEED_OF_SOUND_METERS_PER_SECOND / referenceFrequencyHz,
|
||||
periodMilliseconds: 1000 / referenceFrequencyHz,
|
||||
semitoneOffsetFromA4: referenceMidiNumber - 69,
|
||||
octaveSeries,
|
||||
searchText: normalize([
|
||||
definition.label,
|
||||
definition.family,
|
||||
definition.keyColor,
|
||||
definition.description,
|
||||
definition.usage,
|
||||
INTERVALS_FROM_C[definition.pitchClass],
|
||||
...(definition.aliases || [])
|
||||
].join(" "))
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function findEntryById(id) {
|
||||
return state.entries.find((entry) => entry.id === id) || null;
|
||||
}
|
||||
|
||||
function getAudioContext() {
|
||||
if (state.audioContext) {
|
||||
return state.audioContext;
|
||||
}
|
||||
|
||||
const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
|
||||
if (typeof AudioContextCtor !== "function") {
|
||||
return null;
|
||||
}
|
||||
|
||||
state.audioContext = new AudioContextCtor();
|
||||
return state.audioContext;
|
||||
}
|
||||
|
||||
function clearStopTimer() {
|
||||
if (state.stopTimerId) {
|
||||
window.clearTimeout(state.stopTimerId);
|
||||
state.stopTimerId = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function clearActivePlayback(shouldRender = true) {
|
||||
state.oscillatorNode = null;
|
||||
state.gainNode = null;
|
||||
state.activePlaybackKey = "";
|
||||
clearStopTimer();
|
||||
if (shouldRender) {
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
function stopPlayback(shouldRender = true) {
|
||||
clearStopTimer();
|
||||
|
||||
const oscillatorNode = state.oscillatorNode;
|
||||
const gainNode = state.gainNode;
|
||||
const audioContext = state.audioContext;
|
||||
|
||||
state.oscillatorNode = null;
|
||||
state.gainNode = null;
|
||||
state.activePlaybackKey = "";
|
||||
|
||||
if (oscillatorNode && gainNode && audioContext) {
|
||||
const now = audioContext.currentTime;
|
||||
gainNode.gain.cancelScheduledValues(now);
|
||||
gainNode.gain.setValueAtTime(Math.max(gainNode.gain.value, 0.0001), now);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.0001, now + 0.06);
|
||||
try {
|
||||
oscillatorNode.stop(now + 0.07);
|
||||
} catch (_error) {
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldRender) {
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
async function playFrequency(frequencyHz, playbackKey) {
|
||||
const audioContext = getAudioContext();
|
||||
if (!audioContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.activePlaybackKey === playbackKey) {
|
||||
stopPlayback(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (audioContext.state === "suspended") {
|
||||
await audioContext.resume();
|
||||
}
|
||||
|
||||
stopPlayback(false);
|
||||
|
||||
const oscillatorNode = audioContext.createOscillator();
|
||||
const gainNode = audioContext.createGain();
|
||||
const now = audioContext.currentTime;
|
||||
|
||||
oscillatorNode.type = "sine";
|
||||
oscillatorNode.frequency.setValueAtTime(frequencyHz, now);
|
||||
|
||||
gainNode.gain.setValueAtTime(0.0001, now);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.16, now + 0.03);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.0001, now + 1.1);
|
||||
|
||||
oscillatorNode.connect(gainNode);
|
||||
gainNode.connect(audioContext.destination);
|
||||
|
||||
state.oscillatorNode = oscillatorNode;
|
||||
state.gainNode = gainNode;
|
||||
state.activePlaybackKey = playbackKey;
|
||||
|
||||
oscillatorNode.onended = () => {
|
||||
if (state.oscillatorNode === oscillatorNode || state.activePlaybackKey === playbackKey) {
|
||||
clearActivePlayback(true);
|
||||
}
|
||||
};
|
||||
|
||||
oscillatorNode.start(now);
|
||||
oscillatorNode.stop(now + 1.12);
|
||||
state.stopTimerId = window.setTimeout(() => {
|
||||
clearActivePlayback(true);
|
||||
}, 1250);
|
||||
|
||||
render();
|
||||
}
|
||||
|
||||
function createMetaCard(title) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "planet-meta-card";
|
||||
|
||||
const heading = document.createElement("strong");
|
||||
heading.textContent = title;
|
||||
card.appendChild(heading);
|
||||
return card;
|
||||
}
|
||||
|
||||
function createFrequencyGrid(entry) {
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "audio-note-frequency-grid";
|
||||
|
||||
entry.octaveSeries.forEach((seriesEntry) => {
|
||||
const playbackKey = `${entry.id}:${seriesEntry.octave}`;
|
||||
const isPlaying = state.activePlaybackKey === playbackKey;
|
||||
const cell = document.createElement("button");
|
||||
cell.type = "button";
|
||||
cell.className = `audio-note-frequency-cell audio-note-frequency-cell--button${isPlaying ? " is-playing" : ""}`;
|
||||
cell.dataset.action = "play-frequency";
|
||||
cell.dataset.playbackKey = playbackKey;
|
||||
cell.dataset.frequencyHz = String(seriesEntry.frequencyHz);
|
||||
cell.setAttribute("aria-pressed", isPlaying ? "true" : "false");
|
||||
cell.setAttribute("aria-label", `${isPlaying ? "Stop" : "Play"} ${seriesEntry.label} at ${formatFrequencyHz(seriesEntry.frequencyHz)}`);
|
||||
|
||||
const label = document.createElement("div");
|
||||
label.className = "audio-note-frequency-label";
|
||||
label.textContent = seriesEntry.label;
|
||||
|
||||
const value = document.createElement("div");
|
||||
value.className = "audio-note-frequency-value";
|
||||
value.textContent = formatFrequencyHz(seriesEntry.frequencyHz);
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "audio-note-frequency-meta";
|
||||
meta.textContent = `MIDI ${seriesEntry.midiNumber}`;
|
||||
|
||||
const status = document.createElement("div");
|
||||
status.className = "audio-note-frequency-status";
|
||||
status.textContent = isPlaying ? "Playing now" : "Tap to hear";
|
||||
|
||||
cell.append(label, value, meta, status);
|
||||
grid.appendChild(cell);
|
||||
});
|
||||
|
||||
return grid;
|
||||
}
|
||||
|
||||
function renderList(elements) {
|
||||
if (!elements.listEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
state.filteredEntries.forEach((entry) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "planet-list-item audio-note-list-item";
|
||||
button.dataset.noteId = entry.id;
|
||||
button.setAttribute("role", "option");
|
||||
|
||||
const isSelected = entry.id === state.selectedId;
|
||||
button.classList.toggle("is-selected", isSelected);
|
||||
button.setAttribute("aria-selected", isSelected ? "true" : "false");
|
||||
|
||||
const name = document.createElement("span");
|
||||
name.className = "planet-list-name";
|
||||
name.textContent = entry.label;
|
||||
|
||||
const meta = document.createElement("span");
|
||||
meta.className = "planet-list-meta";
|
||||
meta.textContent = `${entry.referenceLabel} = ${formatFrequencyHz(entry.referenceFrequencyHz)} · ${entry.intervalFromC}`;
|
||||
|
||||
button.append(name, meta);
|
||||
fragment.appendChild(button);
|
||||
});
|
||||
|
||||
elements.listEl.replaceChildren(fragment);
|
||||
|
||||
if (!state.filteredEntries.length) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "planet-text";
|
||||
empty.style.padding = "16px";
|
||||
empty.style.color = "#71717a";
|
||||
empty.textContent = "No notes match your search.";
|
||||
elements.listEl.appendChild(empty);
|
||||
}
|
||||
|
||||
if (elements.countEl) {
|
||||
elements.countEl.textContent = `${state.filteredEntries.length} notes`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderDetail(elements) {
|
||||
const entry = findEntryById(state.selectedId);
|
||||
if (!elements.detailNameEl || !elements.detailSubEl || !elements.detailSummaryEl || !elements.detailBodyEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
elements.detailBodyEl.replaceChildren();
|
||||
|
||||
if (!entry) {
|
||||
elements.detailNameEl.textContent = "--";
|
||||
elements.detailSubEl.textContent = "Select a note to explore";
|
||||
elements.detailSummaryEl.textContent = "--";
|
||||
return;
|
||||
}
|
||||
|
||||
elements.detailNameEl.textContent = entry.label;
|
||||
elements.detailSubEl.textContent = `${entry.family} note · ${entry.intervalFromC}`;
|
||||
elements.detailSummaryEl.textContent = entry.description;
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "planet-meta-grid";
|
||||
|
||||
const profileCard = createMetaCard("Note Profile");
|
||||
profileCard.innerHTML += `
|
||||
<dl class="alpha-dl">
|
||||
<dt>Reference pitch</dt><dd>${entry.referenceLabel}</dd>
|
||||
<dt>Reference frequency</dt><dd>${formatFrequencyHz(entry.referenceFrequencyHz)}</dd>
|
||||
<dt>Pitch class</dt><dd>${entry.pitchClass}</dd>
|
||||
<dt>Interval above C</dt><dd>${entry.intervalFromC}</dd>
|
||||
<dt>Keyboard key</dt><dd>${entry.keyColor}</dd>
|
||||
<dt>Relation to A4</dt><dd>${formatSignedSemitoneOffset(entry.semitoneOffsetFromA4)}</dd>
|
||||
<dt>Wavelength</dt><dd>${formatMeters(entry.wavelengthMeters)}</dd>
|
||||
<dt>Period</dt><dd>${formatMilliseconds(entry.periodMilliseconds)}</dd>
|
||||
</dl>
|
||||
`;
|
||||
|
||||
const aliasRow = document.createElement("div");
|
||||
aliasRow.className = "audio-note-chip-row";
|
||||
const familyChip = document.createElement("span");
|
||||
familyChip.className = `audio-note-chip audio-note-chip--${entry.family.toLowerCase()}`;
|
||||
familyChip.textContent = entry.family;
|
||||
aliasRow.appendChild(familyChip);
|
||||
|
||||
(entry.aliases || []).forEach((alias) => {
|
||||
const chip = document.createElement("span");
|
||||
chip.className = "audio-note-chip";
|
||||
chip.textContent = alias;
|
||||
aliasRow.appendChild(chip);
|
||||
});
|
||||
|
||||
profileCard.appendChild(aliasRow);
|
||||
|
||||
const explanationCard = createMetaCard("Explanation");
|
||||
const explanation = document.createElement("div");
|
||||
explanation.className = "audio-note-copy";
|
||||
explanation.textContent = `${entry.description} ${entry.usage}`;
|
||||
explanationCard.appendChild(explanation);
|
||||
|
||||
const playbackCard = createMetaCard("Tone Preview");
|
||||
const playbackCopy = document.createElement("div");
|
||||
playbackCopy.className = "audio-note-playback-copy";
|
||||
playbackCopy.textContent = `Play ${entry.referenceLabel} as a quick sine-wave reference, or use any octave button below to hear that exact frequency.`;
|
||||
|
||||
const playbackActions = document.createElement("div");
|
||||
playbackActions.className = "audio-note-playback-actions";
|
||||
|
||||
const playReferenceButton = document.createElement("button");
|
||||
playReferenceButton.type = "button";
|
||||
playReferenceButton.className = "alpha-nav-btn audio-note-playback-btn";
|
||||
playReferenceButton.dataset.action = "play-frequency";
|
||||
playReferenceButton.dataset.playbackKey = `${entry.id}:${entry.referenceOctave}`;
|
||||
playReferenceButton.dataset.frequencyHz = String(entry.referenceFrequencyHz);
|
||||
playReferenceButton.textContent = `Play ${entry.referenceLabel}`;
|
||||
|
||||
const stopButton = document.createElement("button");
|
||||
stopButton.type = "button";
|
||||
stopButton.className = "alpha-nav-btn alpha-nav-btn--ghost audio-note-playback-btn";
|
||||
stopButton.dataset.action = "stop-playback";
|
||||
stopButton.textContent = "Stop";
|
||||
|
||||
playbackActions.append(playReferenceButton, stopButton);
|
||||
playbackCard.append(playbackCopy, playbackActions);
|
||||
|
||||
const frequencyCard = createMetaCard("Frequencies By Octave");
|
||||
frequencyCard.appendChild(createFrequencyGrid(entry));
|
||||
|
||||
const formulaCard = createMetaCard("Equal-Temperament Rule");
|
||||
const formula = document.createElement("div");
|
||||
formula.className = "audio-note-formula";
|
||||
formula.textContent = "Reference frequencies here use twelve-tone equal temperament with A4 = 440 Hz. Every octave doubles the frequency, and each semitone step multiplies the pitch by the twelfth root of 2.";
|
||||
formulaCard.appendChild(formula);
|
||||
|
||||
grid.append(profileCard, explanationCard, playbackCard, frequencyCard, formulaCard);
|
||||
elements.detailBodyEl.appendChild(grid);
|
||||
}
|
||||
|
||||
function render() {
|
||||
const elements = getElements();
|
||||
renderList(elements);
|
||||
renderDetail(elements);
|
||||
}
|
||||
|
||||
function applyFilter() {
|
||||
const query = normalize(state.searchQuery);
|
||||
state.filteredEntries = state.entries.filter((entry) => !query || entry.searchText.includes(query));
|
||||
|
||||
if (!state.filteredEntries.some((entry) => entry.id === state.selectedId)) {
|
||||
state.selectedId = state.filteredEntries[0]?.id || "";
|
||||
}
|
||||
|
||||
render();
|
||||
|
||||
const elements = getElements();
|
||||
if (elements.searchClearEl) {
|
||||
elements.searchClearEl.disabled = !state.searchQuery;
|
||||
}
|
||||
}
|
||||
|
||||
function selectNote(id) {
|
||||
if (!findEntryById(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
stopPlayback(false);
|
||||
state.selectedId = id;
|
||||
render();
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
const elements = getElements();
|
||||
if (!elements.listEl || !elements.searchEl || !elements.searchClearEl || !elements.detailBodyEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
elements.listEl.addEventListener("click", (event) => {
|
||||
const button = event.target instanceof Element ? event.target.closest("button[data-note-id]") : null;
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
selectNote(button.dataset.noteId || "");
|
||||
});
|
||||
|
||||
elements.searchEl.addEventListener("input", () => {
|
||||
state.searchQuery = elements.searchEl.value || "";
|
||||
applyFilter();
|
||||
});
|
||||
|
||||
elements.searchClearEl.addEventListener("click", () => {
|
||||
state.searchQuery = "";
|
||||
elements.searchEl.value = "";
|
||||
applyFilter();
|
||||
elements.searchEl.focus();
|
||||
});
|
||||
|
||||
elements.detailBodyEl.addEventListener("click", (event) => {
|
||||
const control = event.target instanceof Element ? event.target.closest("button[data-action]") : null;
|
||||
if (!control) {
|
||||
return;
|
||||
}
|
||||
|
||||
const action = control.dataset.action || "";
|
||||
if (action === "stop-playback") {
|
||||
stopPlayback(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "play-frequency") {
|
||||
const frequencyHz = Number(control.dataset.frequencyHz);
|
||||
const playbackKey = String(control.dataset.playbackKey || "").trim();
|
||||
if (!Number.isFinite(frequencyHz) || !playbackKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
void playFrequency(frequencyHz, playbackKey);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("beforeunload", () => {
|
||||
stopPlayback(false);
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
if (state.initialized) {
|
||||
render();
|
||||
return;
|
||||
}
|
||||
|
||||
state.entries = buildEntries();
|
||||
state.filteredEntries = [...state.entries];
|
||||
state.selectedId = state.entries[0]?.id || "";
|
||||
|
||||
bindEvents();
|
||||
render();
|
||||
state.initialized = true;
|
||||
}
|
||||
|
||||
function ensureAudioNotesSection() {
|
||||
init();
|
||||
}
|
||||
|
||||
window.AudioNotesUi = {
|
||||
...(window.AudioNotesUi || {}),
|
||||
init,
|
||||
ensureAudioNotesSection,
|
||||
selectNote
|
||||
};
|
||||
})();
|
||||
@@ -140,6 +140,28 @@
|
||||
return Boolean(target.closest(AUTO_COLLAPSE_ENTRY_SELECTOR));
|
||||
}
|
||||
|
||||
function getAutoCollapseLayoutFromTarget(target) {
|
||||
if (!(target instanceof Element)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (target.closest(AUTO_COLLAPSE_IGNORE_SELECTOR)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entry = target.closest(AUTO_COLLAPSE_ENTRY_SELECTOR);
|
||||
if (!(entry instanceof Element)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const panel = entry.closest("aside.planet-list-panel, aside.tarot-list-panel, aside.kab-tree-panel");
|
||||
if (!(panel instanceof HTMLElement)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return resolveLayoutTarget(panel);
|
||||
}
|
||||
|
||||
function scheduleAutoCollapse(layout) {
|
||||
if (!(layout instanceof HTMLElement)) {
|
||||
return;
|
||||
@@ -151,46 +173,35 @@
|
||||
}
|
||||
|
||||
function initializeSidebarAutoCollapse() {
|
||||
const layouts = document.querySelectorAll(".planet-layout, .tarot-layout, .kab-layout");
|
||||
if (!document.body || document.body.dataset.sidebarAutoCollapseReady === "1") {
|
||||
return;
|
||||
}
|
||||
|
||||
layouts.forEach((layout) => {
|
||||
document.body.dataset.sidebarAutoCollapseReady = "1";
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
const target = event.target instanceof Element ? event.target : null;
|
||||
const layout = getAutoCollapseLayoutFromTarget(target);
|
||||
if (!(layout instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const panel = Array.from(layout.children).find((child) => (
|
||||
child instanceof HTMLElement
|
||||
&& child.matches("aside.planet-list-panel, aside.tarot-list-panel, aside.kab-tree-panel")
|
||||
));
|
||||
scheduleAutoCollapse(layout);
|
||||
}, true);
|
||||
|
||||
if (!(panel instanceof HTMLElement) || panel.dataset.sidebarAutoCollapseReady === "1") {
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") {
|
||||
return;
|
||||
}
|
||||
|
||||
panel.dataset.sidebarAutoCollapseReady = "1";
|
||||
const target = event.target instanceof Element ? event.target : null;
|
||||
const layout = getAutoCollapseLayoutFromTarget(target);
|
||||
if (!(layout instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
panel.addEventListener("click", (event) => {
|
||||
const target = event.target instanceof Element ? event.target : null;
|
||||
if (!shouldAutoCollapseFromEvent(panel, target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
scheduleAutoCollapse(layout);
|
||||
});
|
||||
|
||||
panel.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = event.target instanceof Element ? event.target : null;
|
||||
if (!shouldAutoCollapseFromEvent(panel, target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
scheduleAutoCollapse(layout);
|
||||
});
|
||||
});
|
||||
scheduleAutoCollapse(layout);
|
||||
}, true);
|
||||
}
|
||||
|
||||
function initializeSidebarPopouts() {
|
||||
@@ -361,6 +372,7 @@
|
||||
|
||||
const nextOpen = Boolean(isOpen);
|
||||
topbarEl.classList.toggle("is-menu-open", nextOpen);
|
||||
document.body.classList.toggle("topbar-menu-open", nextOpen);
|
||||
menuToggleEl.setAttribute("aria-expanded", nextOpen ? "true" : "false");
|
||||
menuToggleEl.textContent = nextOpen ? "Close" : "Menu";
|
||||
menuToggleEl.setAttribute("aria-label", nextOpen ? "Close navigation menu" : "Open navigation menu");
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
let config = {};
|
||||
let lastNowSkyGeoKey = "";
|
||||
let lastNowSkySourceUrl = "";
|
||||
const NOW_SKY_WRAPPER_PATH = "app/stellarium-now-wrapper.html";
|
||||
const NOW_SKY_WRAPPER_PATH = "app/stellarium-now-wrapper.html?v=20260314-now-sky-mobile-04";
|
||||
const NOW_SKY_FOV_DEGREES = "220";
|
||||
|
||||
function getNowSkyLayerEl() {
|
||||
|
||||
@@ -37,6 +37,20 @@
|
||||
setActiveSection("home");
|
||||
});
|
||||
|
||||
bindClick(elements.openAudioEl, () => {
|
||||
const activeSection = getActiveSection();
|
||||
const isAudioSectionActive = activeSection === "audio-notes" || activeSection === "audio-circle";
|
||||
setActiveSection(isAudioSectionActive ? "home" : "audio-notes");
|
||||
});
|
||||
|
||||
bindClick(elements.openAudioCircleEl, () => {
|
||||
setActiveSection("audio-circle");
|
||||
});
|
||||
|
||||
bindClick(elements.openAudioNotesEl, () => {
|
||||
setActiveSection("audio-notes");
|
||||
});
|
||||
|
||||
bindClick(elements.openTarotEl, () => {
|
||||
if (getActiveSection() === "tarot") {
|
||||
setActiveSection("home");
|
||||
|
||||
@@ -24,6 +24,21 @@
|
||||
{ id: "pluto", astronomyBody: "Pluto", fallbackName: "Pluto", fallbackSymbol: "♇︎" }
|
||||
];
|
||||
|
||||
const NOW_PLANET_SIGN_LAYOUT = [
|
||||
{ id: "aries", name: "Aries", symbol: "♈" },
|
||||
{ id: "libra", name: "Libra", symbol: "♎" },
|
||||
{ id: "taurus", name: "Taurus", symbol: "♉" },
|
||||
{ id: "scorpio", name: "Scorpio", symbol: "♏" },
|
||||
{ id: "gemini", name: "Gemini", symbol: "♊" },
|
||||
{ id: "sagittarius", name: "Sagittarius", symbol: "♐" },
|
||||
{ id: "cancer", name: "Cancer", symbol: "♋" },
|
||||
{ id: "capricorn", name: "Capricorn", symbol: "♑" },
|
||||
{ id: "leo", name: "Leo", symbol: "♌" },
|
||||
{ id: "aquarius", name: "Aquarius", symbol: "♒" },
|
||||
{ id: "virgo", name: "Virgo", symbol: "♍" },
|
||||
{ id: "pisces", name: "Pisces", symbol: "♓" }
|
||||
];
|
||||
|
||||
function resetNowLightboxZoom() {
|
||||
if (!nowLightboxImageEl) {
|
||||
return;
|
||||
@@ -302,22 +317,94 @@
|
||||
return positions;
|
||||
}
|
||||
|
||||
function normalizeNowSignId(sign) {
|
||||
if (!sign) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const signId = String(sign?.id || sign).trim().toLowerCase();
|
||||
if (signId) {
|
||||
return signId;
|
||||
}
|
||||
|
||||
return String(sign?.name || "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function buildNowPlanetSignBuckets(planetPositions = []) {
|
||||
const positionsBySign = new Map();
|
||||
|
||||
planetPositions.forEach((position) => {
|
||||
const signId = normalizeNowSignId(position?.sign);
|
||||
if (!signId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!positionsBySign.has(signId)) {
|
||||
positionsBySign.set(signId, []);
|
||||
}
|
||||
|
||||
positionsBySign.get(signId).push(position);
|
||||
});
|
||||
|
||||
return NOW_PLANET_SIGN_LAYOUT.map((signMeta) => ({
|
||||
...signMeta,
|
||||
positions: (positionsBySign.get(signMeta.id) || [])
|
||||
.slice()
|
||||
.sort((left, right) => Number(left?.degreeInSign || 0) - Number(right?.degreeInSign || 0))
|
||||
})).filter((signBucket) => signBucket.positions.length > 0);
|
||||
}
|
||||
|
||||
function formatNowPlanetEntry(position) {
|
||||
const symbol = String(position?.symbol || "").trim();
|
||||
const name = String(position?.name || "").trim() || "--";
|
||||
const degree = Number(position?.degreeInSign);
|
||||
const degreeLabel = Number.isFinite(degree) ? `${degree.toFixed(1)}°` : "--";
|
||||
|
||||
return `${symbol ? `${symbol} ` : ""}${name} ${degreeLabel}`.trim();
|
||||
}
|
||||
|
||||
function renderNowPlanetPositions(containerEl, planetPositions = []) {
|
||||
if (!containerEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
containerEl.replaceChildren();
|
||||
|
||||
const positions = Array.isArray(planetPositions) ? planetPositions.filter(Boolean) : [];
|
||||
if (!positions.length) {
|
||||
containerEl.textContent = "--";
|
||||
return;
|
||||
}
|
||||
|
||||
buildNowPlanetSignBuckets(positions).forEach((signBucket) => {
|
||||
const item = document.createElement("div");
|
||||
item.className = "now-stats-planet";
|
||||
|
||||
const title = document.createElement("div");
|
||||
title.className = "now-stats-planet-sign";
|
||||
title.textContent = `${signBucket.symbol} ${signBucket.name}`;
|
||||
item.appendChild(title);
|
||||
|
||||
const list = document.createElement("div");
|
||||
list.className = "now-stats-planet-list";
|
||||
|
||||
signBucket.positions.forEach((position) => {
|
||||
const entry = document.createElement("div");
|
||||
entry.className = "now-stats-planet-entry";
|
||||
entry.textContent = formatNowPlanetEntry(position);
|
||||
list.appendChild(entry);
|
||||
});
|
||||
|
||||
item.appendChild(list);
|
||||
containerEl.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
function updateNowStats(referenceData, elements, now) {
|
||||
const planetPositions = calculatePlanetPositions(referenceData, now);
|
||||
|
||||
if (elements.nowStatsPlanetsEl) {
|
||||
elements.nowStatsPlanetsEl.replaceChildren();
|
||||
|
||||
if (!planetPositions.length) {
|
||||
elements.nowStatsPlanetsEl.textContent = "--";
|
||||
} else {
|
||||
planetPositions.forEach((position) => {
|
||||
const item = document.createElement("div");
|
||||
item.className = "now-stats-planet";
|
||||
item.textContent = position.label;
|
||||
elements.nowStatsPlanetsEl.appendChild(item);
|
||||
});
|
||||
}
|
||||
renderNowPlanetPositions(elements.nowStatsPlanetsEl, planetPositions);
|
||||
}
|
||||
|
||||
if (elements.nowStatsSabianEl) {
|
||||
@@ -524,6 +611,7 @@
|
||||
formatCountdown,
|
||||
getDisplayTarotName,
|
||||
setNowCardImage,
|
||||
renderNowPlanetPositions,
|
||||
updateNowStats
|
||||
};
|
||||
})();
|
||||
@@ -19,6 +19,7 @@
|
||||
|| typeof nowUiHelpers.getSignStartDate !== "function"
|
||||
|| typeof nowUiHelpers.getDisplayTarotName !== "function"
|
||||
|| typeof nowUiHelpers.setNowCardImage !== "function"
|
||||
|| typeof nowUiHelpers.renderNowPlanetPositions !== "function"
|
||||
|| typeof nowUiHelpers.updateNowStats !== "function"
|
||||
) {
|
||||
throw new Error("NowUiHelpers module must load before ui-now.js");
|
||||
@@ -41,19 +42,10 @@
|
||||
|
||||
function renderNowStatsFromSnapshot(elements, stats) {
|
||||
if (elements.nowStatsPlanetsEl) {
|
||||
elements.nowStatsPlanetsEl.replaceChildren();
|
||||
|
||||
const planetPositions = Array.isArray(stats?.planetPositions) ? stats.planetPositions : [];
|
||||
if (!planetPositions.length) {
|
||||
elements.nowStatsPlanetsEl.textContent = "--";
|
||||
} else {
|
||||
planetPositions.forEach((position) => {
|
||||
const item = document.createElement("div");
|
||||
item.className = "now-stats-planet";
|
||||
item.textContent = String(position?.label || "").trim() || "--";
|
||||
elements.nowStatsPlanetsEl.appendChild(item);
|
||||
});
|
||||
}
|
||||
nowUiHelpers.renderNowPlanetPositions(
|
||||
elements.nowStatsPlanetsEl,
|
||||
Array.isArray(stats?.planetPositions) ? stats.planetPositions : []
|
||||
);
|
||||
}
|
||||
|
||||
if (elements.nowStatsSabianEl) {
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
"timeline",
|
||||
"calendar",
|
||||
"holidays",
|
||||
"audio-circle",
|
||||
"audio-notes",
|
||||
"tarot",
|
||||
"tarot-house",
|
||||
"astronomy",
|
||||
@@ -88,6 +90,9 @@
|
||||
const isCalendarOpen = activeSection === "calendar";
|
||||
const isHolidaysOpen = activeSection === "holidays";
|
||||
const isCalendarMenuOpen = isTimelineOpen || isCalendarOpen || isHolidaysOpen;
|
||||
const isAudioNotesOpen = activeSection === "audio-notes";
|
||||
const isAudioCircleOpen = activeSection === "audio-circle";
|
||||
const isAudioMenuOpen = isAudioNotesOpen || isAudioCircleOpen;
|
||||
const isTarotOpen = activeSection === "tarot";
|
||||
const isTarotHouseOpen = activeSection === "tarot-house";
|
||||
const isTarotMenuOpen = isTarotOpen || isTarotHouseOpen;
|
||||
@@ -115,6 +120,8 @@
|
||||
setHidden(elements.timelineSectionEl, !isTimelineOpen);
|
||||
setHidden(elements.calendarSectionEl, !isCalendarOpen);
|
||||
setHidden(elements.holidaySectionEl, !isHolidaysOpen);
|
||||
setHidden(elements.audioCircleSectionEl, !isAudioCircleOpen);
|
||||
setHidden(elements.audioNotesSectionEl, !isAudioNotesOpen);
|
||||
setHidden(elements.tarotSectionEl, !isTarotOpen);
|
||||
setHidden(elements.tarotHouseSectionEl, !isTarotHouseOpen);
|
||||
setHidden(elements.astronomySectionEl, !isAstronomyOpen);
|
||||
@@ -141,6 +148,9 @@
|
||||
toggleActive(elements.openCalendarTimelineEl, isTimelineOpen);
|
||||
toggleActive(elements.openCalendarMonthsEl, isCalendarOpen);
|
||||
toggleActive(elements.openHolidaysEl, isHolidaysOpen);
|
||||
setPressed(elements.openAudioEl, isAudioMenuOpen);
|
||||
toggleActive(elements.openAudioCircleEl, isAudioCircleOpen);
|
||||
toggleActive(elements.openAudioNotesEl, isAudioNotesOpen);
|
||||
setPressed(elements.openTarotEl, isTarotMenuOpen);
|
||||
toggleActive(elements.openTarotHouseEl, isTarotHouseOpen);
|
||||
config.tarotSpreadUi?.applyViewState?.();
|
||||
@@ -181,6 +191,16 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAudioCircleOpen) {
|
||||
ensure.ensureAudioCircleSection?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAudioNotesOpen) {
|
||||
ensure.ensureAudioNotesSection?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTarotOpen) {
|
||||
if (typeof config.tarotSpreadUi?.handleSectionActivated === "function") {
|
||||
config.tarotSpreadUi.handleSectionActivated();
|
||||
|
||||
Reference in New Issue
Block a user