update ui and add new audio components
This commit is contained in:
22
app.js
22
app.js
@@ -6,6 +6,8 @@ const { ensureTarotSection } = window.TarotSectionUi || {};
|
|||||||
const { ensurePlanetSection } = window.PlanetSectionUi || {};
|
const { ensurePlanetSection } = window.PlanetSectionUi || {};
|
||||||
const { ensureCyclesSection } = window.CyclesSectionUi || {};
|
const { ensureCyclesSection } = window.CyclesSectionUi || {};
|
||||||
const { ensureElementsSection } = window.ElementsSectionUi || {};
|
const { ensureElementsSection } = window.ElementsSectionUi || {};
|
||||||
|
const { ensureAudioCircleSection } = window.AudioCircleUi || {};
|
||||||
|
const { ensureAudioNotesSection } = window.AudioNotesUi || {};
|
||||||
const { ensureIChingSection } = window.IChingSectionUi || {};
|
const { ensureIChingSection } = window.IChingSectionUi || {};
|
||||||
const { ensureKabbalahSection } = window.KabbalahSectionUi || {};
|
const { ensureKabbalahSection } = window.KabbalahSectionUi || {};
|
||||||
const { ensureCubeSection } = window.CubeSectionUi || {};
|
const { ensureCubeSection } = window.CubeSectionUi || {};
|
||||||
@@ -35,6 +37,8 @@ const calendarEl = document.getElementById("calendar");
|
|||||||
const timelineSectionEl = document.getElementById("timeline-section");
|
const timelineSectionEl = document.getElementById("timeline-section");
|
||||||
const calendarSectionEl = document.getElementById("calendar-section");
|
const calendarSectionEl = document.getElementById("calendar-section");
|
||||||
const holidaySectionEl = document.getElementById("holiday-section");
|
const holidaySectionEl = document.getElementById("holiday-section");
|
||||||
|
const audioCircleSectionEl = document.getElementById("audio-circle-section");
|
||||||
|
const audioNotesSectionEl = document.getElementById("audio-notes-section");
|
||||||
const tarotSectionEl = document.getElementById("tarot-section");
|
const tarotSectionEl = document.getElementById("tarot-section");
|
||||||
const tarotHouseSectionEl = document.getElementById("tarot-house-section");
|
const tarotHouseSectionEl = document.getElementById("tarot-house-section");
|
||||||
const astronomySectionEl = document.getElementById("astronomy-section");
|
const astronomySectionEl = document.getElementById("astronomy-section");
|
||||||
@@ -59,6 +63,9 @@ const openCalendarEl = document.getElementById("open-calendar");
|
|||||||
const openCalendarTimelineEl = document.getElementById("open-calendar-timeline");
|
const openCalendarTimelineEl = document.getElementById("open-calendar-timeline");
|
||||||
const openCalendarMonthsEl = document.getElementById("open-calendar-months");
|
const openCalendarMonthsEl = document.getElementById("open-calendar-months");
|
||||||
const openHolidaysEl = document.getElementById("open-holidays");
|
const openHolidaysEl = document.getElementById("open-holidays");
|
||||||
|
const openAudioEl = document.getElementById("open-audio");
|
||||||
|
const openAudioCircleEl = document.getElementById("open-audio-circle");
|
||||||
|
const openAudioNotesEl = document.getElementById("open-audio-notes");
|
||||||
const openTarotEl = document.getElementById("open-tarot");
|
const openTarotEl = document.getElementById("open-tarot");
|
||||||
const openTarotHouseEl = document.getElementById("open-tarot-house");
|
const openTarotHouseEl = document.getElementById("open-tarot-house");
|
||||||
const openAstronomyEl = document.getElementById("open-astronomy");
|
const openAstronomyEl = document.getElementById("open-astronomy");
|
||||||
@@ -400,6 +407,8 @@ sectionStateUi.init?.({
|
|||||||
timelineSectionEl,
|
timelineSectionEl,
|
||||||
calendarSectionEl,
|
calendarSectionEl,
|
||||||
holidaySectionEl,
|
holidaySectionEl,
|
||||||
|
audioCircleSectionEl,
|
||||||
|
audioNotesSectionEl,
|
||||||
tarotSectionEl,
|
tarotSectionEl,
|
||||||
tarotHouseSectionEl,
|
tarotHouseSectionEl,
|
||||||
astronomySectionEl,
|
astronomySectionEl,
|
||||||
@@ -424,6 +433,9 @@ sectionStateUi.init?.({
|
|||||||
openCalendarTimelineEl,
|
openCalendarTimelineEl,
|
||||||
openCalendarMonthsEl,
|
openCalendarMonthsEl,
|
||||||
openHolidaysEl,
|
openHolidaysEl,
|
||||||
|
openAudioEl,
|
||||||
|
openAudioCircleEl,
|
||||||
|
openAudioNotesEl,
|
||||||
openTarotEl,
|
openTarotEl,
|
||||||
openTarotHouseEl,
|
openTarotHouseEl,
|
||||||
openAstronomyEl,
|
openAstronomyEl,
|
||||||
@@ -461,7 +473,9 @@ sectionStateUi.init?.({
|
|||||||
ensureCalendarSection,
|
ensureCalendarSection,
|
||||||
ensureHolidaySection,
|
ensureHolidaySection,
|
||||||
ensureNatalPanel,
|
ensureNatalPanel,
|
||||||
ensureNumbersSection
|
ensureNumbersSection,
|
||||||
|
ensureAudioCircleSection,
|
||||||
|
ensureAudioNotesSection
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -520,6 +534,9 @@ navigationUi.init?.({
|
|||||||
openCalendarTimelineEl,
|
openCalendarTimelineEl,
|
||||||
openCalendarMonthsEl,
|
openCalendarMonthsEl,
|
||||||
openHolidaysEl,
|
openHolidaysEl,
|
||||||
|
openAudioEl,
|
||||||
|
openAudioCircleEl,
|
||||||
|
openAudioNotesEl,
|
||||||
openTarotEl,
|
openTarotEl,
|
||||||
openTarotHouseEl,
|
openTarotHouseEl,
|
||||||
openAstronomyEl,
|
openAstronomyEl,
|
||||||
@@ -552,7 +569,8 @@ navigationUi.init?.({
|
|||||||
ensureAlphabetTextSection,
|
ensureAlphabetTextSection,
|
||||||
ensureZodiacSection,
|
ensureZodiacSection,
|
||||||
ensureGodsSection,
|
ensureGodsSection,
|
||||||
ensureCalendarSection
|
ensureCalendarSection,
|
||||||
|
ensureAudioCircleSection
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,14 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Stellarium NOW Wrapper</title>
|
<title>Stellarium NOW Wrapper</title>
|
||||||
<style>
|
<style>
|
||||||
|
:root {
|
||||||
|
--sky-embed-width: 100%;
|
||||||
|
--sky-embed-height: 106%;
|
||||||
|
--sky-embed-left: 0%;
|
||||||
|
--sky-embed-top: -2%;
|
||||||
|
--sky-brand-mask-width: 0%;
|
||||||
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -32,14 +40,45 @@
|
|||||||
|
|
||||||
#sky-embed {
|
#sky-embed {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
left: var(--sky-embed-left);
|
||||||
width: 100%;
|
top: var(--sky-embed-top);
|
||||||
height: 106%;
|
width: var(--sky-embed-width);
|
||||||
top: -2%;
|
height: var(--sky-embed-height);
|
||||||
border: 0;
|
border: 0;
|
||||||
background: #000;
|
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 {
|
#sky-shell::before {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -76,6 +115,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div id="sky-shell">
|
<div id="sky-shell">
|
||||||
<iframe id="sky-embed" title="Decorative sky background" scrolling="no" allow="geolocation"></iframe>
|
<iframe id="sky-embed" title="Decorative sky background" scrolling="no" allow="geolocation"></iframe>
|
||||||
|
<div id="sky-brand-mask" aria-hidden="true"></div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
|
|||||||
542
app/styles.css
542
app/styles.css
@@ -137,6 +137,10 @@
|
|||||||
background: #3f3f46;
|
background: #3f3f46;
|
||||||
}
|
}
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
|
body.topbar-menu-open {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.topbar {
|
.topbar {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -156,9 +160,34 @@
|
|||||||
min-height: 38px;
|
min-height: 38px;
|
||||||
margin-left: auto;
|
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 {
|
.topbar-actions {
|
||||||
max-height: calc(100svh - 88px);
|
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 {
|
.settings-trigger {
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
}
|
}
|
||||||
@@ -175,6 +204,12 @@
|
|||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
max-height: calc(100svh - 78px);
|
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 {
|
.settings-trigger {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
@@ -3338,7 +3373,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.alpha-text-controls--heading {
|
.alpha-text-controls--heading {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
border: 1px solid #2f2f39;
|
border: 1px solid #2f2f39;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
@@ -3398,6 +3433,14 @@
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.alpha-text-control[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alpha-text-control--toggle {
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
.alpha-text-control > span {
|
.alpha-text-control > span {
|
||||||
color: #a1a1aa;
|
color: #a1a1aa;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -3416,6 +3459,11 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.alpha-text-compare-toggle {
|
||||||
|
min-height: 42px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.alpha-text-search-input {
|
.alpha-text-search-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 42px;
|
min-height: 42px;
|
||||||
@@ -3482,6 +3530,10 @@
|
|||||||
.alpha-text-heading-tools {
|
.alpha-text-heading-tools {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.alpha-text-reader-compare {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
@@ -3572,6 +3624,17 @@
|
|||||||
gap: 0;
|
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 {
|
.alpha-text-reader-navigation {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -4210,6 +4273,409 @@
|
|||||||
}
|
}
|
||||||
#elements-section[hidden] { display: none; }
|
#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 {
|
.enoch-list-item {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -4683,6 +5149,10 @@
|
|||||||
}
|
}
|
||||||
#now-panel {
|
#now-panel {
|
||||||
--now-square-size: min(85vmin, calc(100vw - 172px), calc(100svh - 92px));
|
--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;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
height: calc(100svh - 88px);
|
height: calc(100svh - 88px);
|
||||||
@@ -4701,10 +5171,10 @@
|
|||||||
|
|
||||||
#now-sky-layer {
|
#now-sky-layer {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: max(calc(var(--now-square-size) * 3.25), calc(100% + 420px));
|
width: var(--now-sky-width);
|
||||||
height: max(calc(var(--now-square-size) * 2.28), calc(100% + 250px));
|
height: var(--now-sky-height);
|
||||||
top: 50.5%;
|
top: var(--now-sky-top);
|
||||||
left: 44%;
|
left: var(--now-sky-left);
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
transform-origin: center center;
|
transform-origin: center center;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
@@ -4786,6 +5256,34 @@
|
|||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
pointer-events: none;
|
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 {
|
.now-section {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
@@ -4952,16 +5450,36 @@
|
|||||||
}
|
}
|
||||||
.now-stats-planets {
|
.now-stats-planets {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 4px 12px;
|
gap: 6px 14px;
|
||||||
font-size: clamp(15px, 1.9vmin, 17px);
|
font-size: clamp(12px, 1.6vmin, 14px);
|
||||||
line-height: 1.3;
|
line-height: 1.24;
|
||||||
color: #e2e8f0;
|
color: #e2e8f0;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
|
align-items: start;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
.now-stats-planet {
|
.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;
|
white-space: normal;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
text-overflow: clip;
|
text-overflow: clip;
|
||||||
@@ -5386,7 +5904,7 @@
|
|||||||
width: min(64%, 122px);
|
width: min(64%, 122px);
|
||||||
}
|
}
|
||||||
.now-stats-planets {
|
.now-stats-planets {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
.planet-layout,
|
.planet-layout,
|
||||||
.tarot-layout {
|
.tarot-layout {
|
||||||
@@ -5507,6 +6025,7 @@
|
|||||||
}
|
}
|
||||||
.now-stats-planets {
|
.now-stats-planets {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 5px 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5532,7 +6051,8 @@
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
.now-stats-planets {
|
.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 = {
|
const state = {
|
||||||
initialized: false,
|
initialized: false,
|
||||||
catalog: null,
|
catalog: null,
|
||||||
|
selectedSourceGroupId: "",
|
||||||
selectedSourceId: "",
|
selectedSourceId: "",
|
||||||
|
selectedSourceIdByGroup: {},
|
||||||
|
compareSourceIdByGroup: {},
|
||||||
|
compareModeByGroup: {},
|
||||||
selectedWorkId: "",
|
selectedWorkId: "",
|
||||||
selectedSectionId: "",
|
selectedSectionId: "",
|
||||||
currentPassage: null,
|
currentPassage: null,
|
||||||
|
comparePassage: null,
|
||||||
lexiconEntry: null,
|
lexiconEntry: null,
|
||||||
lexiconRequestId: 0,
|
lexiconRequestId: 0,
|
||||||
lexiconOccurrenceResults: null,
|
lexiconOccurrenceResults: null,
|
||||||
@@ -35,6 +40,12 @@
|
|||||||
let globalSearchInputEl;
|
let globalSearchInputEl;
|
||||||
let localSearchFormEl;
|
let localSearchFormEl;
|
||||||
let localSearchInputEl;
|
let localSearchInputEl;
|
||||||
|
let translationSelectEl;
|
||||||
|
let translationControlEl;
|
||||||
|
let compareSelectEl;
|
||||||
|
let compareControlEl;
|
||||||
|
let compareToggleEl;
|
||||||
|
let compareToggleControlEl;
|
||||||
let workSelectEl;
|
let workSelectEl;
|
||||||
let sectionSelectEl;
|
let sectionSelectEl;
|
||||||
let detailHeadingEl;
|
let detailHeadingEl;
|
||||||
@@ -63,6 +74,12 @@
|
|||||||
globalSearchInputEl = document.getElementById("alpha-text-global-search-input");
|
globalSearchInputEl = document.getElementById("alpha-text-global-search-input");
|
||||||
localSearchFormEl = document.getElementById("alpha-text-local-search-form");
|
localSearchFormEl = document.getElementById("alpha-text-local-search-form");
|
||||||
localSearchInputEl = document.getElementById("alpha-text-local-search-input");
|
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");
|
workSelectEl = document.getElementById("alpha-text-work-select");
|
||||||
sectionSelectEl = document.getElementById("alpha-text-section-select");
|
sectionSelectEl = document.getElementById("alpha-text-section-select");
|
||||||
detailHeadingEl = document.querySelector("#alphabet-text-section .alpha-text-detail-heading");
|
detailHeadingEl = document.querySelector("#alphabet-text-section .alpha-text-detail-heading");
|
||||||
@@ -167,13 +184,128 @@
|
|||||||
return Array.isArray(state.catalog?.sources) ? state.catalog.sources : [];
|
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) {
|
function findById(entries, value) {
|
||||||
const needle = normalizeId(value);
|
const needle = normalizeId(value);
|
||||||
return (Array.isArray(entries) ? entries : []).find((entry) => normalizeId(entry?.id) === needle) || null;
|
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() {
|
function getSelectedSource() {
|
||||||
return findById(getSources(), state.selectedSourceId);
|
return getSourceForGroup(getSelectedSourceGroup(), state.selectedSourceId)
|
||||||
|
|| findById(getSources(), state.selectedSourceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSelectedWork(source = getSelectedSource()) {
|
function getSelectedWork(source = getSelectedSource()) {
|
||||||
@@ -188,6 +320,114 @@
|
|||||||
return String(value || "").trim();
|
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 = "") {
|
function extractVerseCountText(verse, source, displayPreferences, translationText = "") {
|
||||||
const mode = displayPreferences?.textMode || "translation";
|
const mode = displayPreferences?.textMode || "translation";
|
||||||
const originalText = normalizeTextValue(verse?.originalText);
|
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) {
|
async function ensureCatalogLoaded(forceRefresh = false) {
|
||||||
if (!forceRefresh && state.catalog) {
|
if (!forceRefresh && state.catalog) {
|
||||||
return state.catalog;
|
return state.catalog;
|
||||||
@@ -637,11 +899,17 @@
|
|||||||
? payload
|
? payload
|
||||||
: { meta: {}, sources: [], lexicons: [] };
|
: { meta: {}, sources: [], lexicons: [] };
|
||||||
|
|
||||||
if (!state.selectedSourceId) {
|
state.catalog.sourceGroups = buildSourceGroups(getSources());
|
||||||
state.selectedSourceId = getSources()[0]?.id || "";
|
|
||||||
|
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;
|
return state.catalog;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -668,39 +936,39 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
sourceListEl.replaceChildren();
|
sourceListEl.replaceChildren();
|
||||||
const sources = getSources();
|
const sourceGroups = getSourceGroups();
|
||||||
sources.forEach((source) => {
|
sourceGroups.forEach((group) => {
|
||||||
|
const source = getSourceForGroup(group);
|
||||||
const button = document.createElement("button");
|
const button = document.createElement("button");
|
||||||
button.type = "button";
|
button.type = "button";
|
||||||
button.className = "planet-list-item alpha-text-source-btn";
|
button.className = "planet-list-item alpha-text-source-btn";
|
||||||
button.dataset.sourceId = source.id;
|
button.dataset.sourceGroupId = group.id;
|
||||||
button.setAttribute("role", "option");
|
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.classList.toggle("is-selected", isSelected);
|
||||||
button.setAttribute("aria-selected", isSelected ? "true" : "false");
|
button.setAttribute("aria-selected", isSelected ? "true" : "false");
|
||||||
|
|
||||||
const name = document.createElement("span");
|
const name = document.createElement("span");
|
||||||
name.className = "planet-list-name";
|
name.className = "planet-list-name";
|
||||||
name.textContent = source.title;
|
name.textContent = group.title;
|
||||||
|
|
||||||
const meta = document.createElement("span");
|
const meta = document.createElement("span");
|
||||||
meta.className = "alpha-text-source-meta";
|
meta.className = "alpha-text-source-meta";
|
||||||
const sectionLabel = source.sectionLabel || "Section";
|
meta.textContent = buildSourceGroupListMeta(group);
|
||||||
meta.textContent = `${source.shortTitle || source.title} · ${source.stats?.workCount || 0} ${source.workLabel || "Works"} · ${source.stats?.sectionCount || 0} ${sectionLabel.toLowerCase()}s`;
|
|
||||||
|
|
||||||
button.append(name, meta);
|
button.append(name, meta);
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
if (normalizeId(source.id) === normalizeId(state.selectedSourceId)) {
|
if (normalizeId(group.id) === normalizeId(state.selectedSourceGroupId)) {
|
||||||
showDetailOnlyMode();
|
showDetailOnlyMode();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
state.selectedSourceId = source.id;
|
state.selectedSourceGroupId = group.id;
|
||||||
state.currentPassage = null;
|
state.currentPassage = null;
|
||||||
state.lexiconEntry = null;
|
state.lexiconEntry = null;
|
||||||
state.highlightedVerseId = "";
|
state.highlightedVerseId = "";
|
||||||
syncSelectionForSource(getSelectedSource());
|
syncSelectionForGroup(group);
|
||||||
renderSourceList();
|
renderSourceList();
|
||||||
renderSelectors();
|
renderSelectors();
|
||||||
showDetailOnlyMode();
|
showDetailOnlyMode();
|
||||||
@@ -716,23 +984,56 @@
|
|||||||
sourceListEl.appendChild(button);
|
sourceListEl.appendChild(button);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!sources.length) {
|
if (!sourceGroups.length) {
|
||||||
sourceListEl.appendChild(createEmptyMessage("No text sources are available."));
|
sourceListEl.appendChild(createEmptyMessage("No text sources are available."));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sourceCountEl) {
|
if (sourceCountEl) {
|
||||||
sourceCountEl.textContent = `${sources.length} sources`;
|
sourceCountEl.textContent = `${sourceGroups.length} sources`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSelectors() {
|
function renderSelectors() {
|
||||||
|
const group = getSelectedSourceGroup();
|
||||||
const source = getSelectedSource();
|
const source = getSelectedSource();
|
||||||
const work = getSelectedWork(source);
|
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 works = Array.isArray(source?.works) ? source.works : [];
|
||||||
const sections = Array.isArray(work?.sections) ? work.sections : [];
|
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(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`);
|
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() {
|
function closeLexiconEntry() {
|
||||||
@@ -1044,9 +1345,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createMetaGrid(passage) {
|
function createMetaGrid(passage) {
|
||||||
|
const sourceGroup = getSelectedSourceGroup();
|
||||||
const source = passage?.source || getSelectedSource();
|
const source = passage?.source || getSelectedSource();
|
||||||
const work = passage?.work || getSelectedWork(source);
|
const work = passage?.work || getSelectedWork(source);
|
||||||
const section = passage?.section || getSelectedSection(source, work);
|
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 displayPreferences = getSourceDisplayPreferences(source, passage);
|
||||||
const metaGrid = document.createElement("div");
|
const metaGrid = document.createElement("div");
|
||||||
metaGrid.className = "alpha-text-meta-grid";
|
metaGrid.className = "alpha-text-meta-grid";
|
||||||
@@ -1055,6 +1361,10 @@
|
|||||||
overviewCard.innerHTML += `
|
overviewCard.innerHTML += `
|
||||||
<dl class="alpha-dl">
|
<dl class="alpha-dl">
|
||||||
<dt>Source</dt><dd>${source?.title || "--"}</dd>
|
<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>Tradition</dt><dd>${source?.tradition || "--"}</dd>
|
||||||
<dt>Language</dt><dd>${source?.language || "--"}</dd>
|
<dt>Language</dt><dd>${source?.language || "--"}</dd>
|
||||||
<dt>Script</dt><dd>${source?.script || "--"}</dd>
|
<dt>Script</dt><dd>${source?.script || "--"}</dd>
|
||||||
@@ -1148,14 +1458,13 @@
|
|||||||
return metaGrid;
|
return metaGrid;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createPlainVerse(verse) {
|
function createPlainVerse(verse, source, displayPreferences, options = {}) {
|
||||||
const source = getSelectedSource();
|
|
||||||
const displayPreferences = getSourceDisplayPreferences(source, state.currentPassage);
|
|
||||||
const translationText = verse.text || "";
|
const translationText = verse.text || "";
|
||||||
const verseCounts = getTextCounts(extractVerseCountText(verse, source, displayPreferences, translationText));
|
const verseCounts = getTextCounts(extractVerseCountText(verse, source, displayPreferences, translationText));
|
||||||
|
const isHighlighted = options.highlight !== false && isHighlightedVerse(verse);
|
||||||
const article = document.createElement("article");
|
const article = document.createElement("article");
|
||||||
article.className = "alpha-text-verse";
|
article.className = "alpha-text-verse";
|
||||||
article.classList.toggle("is-highlighted", isHighlightedVerse(verse));
|
article.classList.toggle("is-highlighted", isHighlighted);
|
||||||
|
|
||||||
const head = document.createElement("div");
|
const head = document.createElement("div");
|
||||||
head.className = "alpha-text-verse-head";
|
head.className = "alpha-text-verse-head";
|
||||||
@@ -1170,7 +1479,7 @@
|
|||||||
|
|
||||||
head.append(reference, stats);
|
head.append(reference, stats);
|
||||||
article.append(head);
|
article.append(head);
|
||||||
appendVerseTextLines(article, verse, source, displayPreferences, translationText);
|
appendVerseTextLines(article, verse, source, displayPreferences, translationText, isHighlighted ? state.searchQuery : "");
|
||||||
return article;
|
return article;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1185,7 +1494,7 @@
|
|||||||
return glossText || String(fallbackText || "").trim();
|
return glossText || String(fallbackText || "").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendVerseTextLines(target, verse, source, displayPreferences, translationText) {
|
function appendVerseTextLines(target, verse, source, displayPreferences, translationText, highlightQuery = "") {
|
||||||
if (!(target instanceof HTMLElement)) {
|
if (!(target instanceof HTMLElement)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1222,18 +1531,19 @@
|
|||||||
lines.forEach((line) => {
|
lines.forEach((line) => {
|
||||||
const text = document.createElement("p");
|
const text = document.createElement("p");
|
||||||
text.className = `alpha-text-verse-text alpha-text-verse-text--${line.variant}`;
|
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);
|
target.appendChild(text);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTokenVerse(verse, lexiconId, displayPreferences, source) {
|
function createTokenVerse(verse, lexiconId, displayPreferences, source, options = {}) {
|
||||||
const translationText = buildTokenTranslationText(verse?.tokens, verse?.text);
|
const translationText = buildTokenTranslationText(verse?.tokens, verse?.text);
|
||||||
const verseCounts = getTextCounts(extractVerseCountText(verse, source, displayPreferences, translationText));
|
const verseCounts = getTextCounts(extractVerseCountText(verse, source, displayPreferences, translationText));
|
||||||
|
const isHighlighted = options.highlight !== false && isHighlightedVerse(verse);
|
||||||
const article = document.createElement("article");
|
const article = document.createElement("article");
|
||||||
article.className = "alpha-text-verse";
|
article.className = "alpha-text-verse";
|
||||||
article.classList.toggle("alpha-text-verse--interlinear", Boolean(displayPreferences?.showInterlinear));
|
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");
|
const head = document.createElement("div");
|
||||||
head.className = "alpha-text-verse-head";
|
head.className = "alpha-text-verse-head";
|
||||||
@@ -1282,43 +1592,14 @@
|
|||||||
|
|
||||||
head.append(reference, stats);
|
head.append(reference, stats);
|
||||||
article.append(head);
|
article.append(head);
|
||||||
appendVerseTextLines(article, verse, source, displayPreferences, translationText);
|
appendVerseTextLines(article, verse, source, displayPreferences, translationText, isHighlighted ? state.searchQuery : "");
|
||||||
if (displayPreferences?.showInterlinear) {
|
if (displayPreferences?.showInterlinear) {
|
||||||
article.appendChild(tokenGrid);
|
article.appendChild(tokenGrid);
|
||||||
}
|
}
|
||||||
return article;
|
return article;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createReaderCard(passage) {
|
function createReaderNavigation(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);
|
|
||||||
|
|
||||||
const navigation = document.createElement("div");
|
const navigation = document.createElement("div");
|
||||||
navigation.className = "alpha-text-reader-navigation";
|
navigation.className = "alpha-text-reader-navigation";
|
||||||
|
|
||||||
@@ -1344,13 +1625,70 @@
|
|||||||
navigation.appendChild(nextButton);
|
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);
|
card.appendChild(navigation);
|
||||||
}
|
}
|
||||||
|
|
||||||
return card;
|
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() {
|
function createSearchCard() {
|
||||||
const hasSearchState = state.searchLoading || state.searchError || state.searchResults || state.searchQuery;
|
const hasSearchState = state.searchLoading || state.searchError || state.searchResults || state.searchQuery;
|
||||||
if (!hasSearchState) {
|
if (!hasSearchState) {
|
||||||
@@ -1443,6 +1781,7 @@
|
|||||||
const source = getSelectedSource();
|
const source = getSelectedSource();
|
||||||
const work = getSelectedWork(source);
|
const work = getSelectedWork(source);
|
||||||
const section = getSelectedSection(source, work);
|
const section = getSelectedSection(source, work);
|
||||||
|
const compareEnabled = isCompareModeEnabled(getSelectedSourceGroup());
|
||||||
const globalSearchOnlyMode = isGlobalSearchOnlyMode();
|
const globalSearchOnlyMode = isGlobalSearchOnlyMode();
|
||||||
setGlobalSearchHeadingMode(globalSearchOnlyMode);
|
setGlobalSearchHeadingMode(globalSearchOnlyMode);
|
||||||
|
|
||||||
@@ -1460,7 +1799,7 @@
|
|||||||
if (detailSubEl) {
|
if (detailSubEl) {
|
||||||
detailSubEl.textContent = globalSearchOnlyMode
|
detailSubEl.textContent = globalSearchOnlyMode
|
||||||
? "All text sources"
|
? "All text sources"
|
||||||
: `${source.title} · ${work.title}`;
|
: buildSourceDetailSubtitle(source, work);
|
||||||
}
|
}
|
||||||
if (!detailBodyEl) {
|
if (!detailBodyEl) {
|
||||||
return;
|
return;
|
||||||
@@ -1487,39 +1826,100 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
detailBodyEl.appendChild(createMetaGrid(state.currentPassage));
|
detailBodyEl.appendChild(createMetaGrid(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));
|
detailBodyEl.appendChild(createReaderCard(state.currentPassage));
|
||||||
|
}
|
||||||
renderLexiconPopup();
|
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() {
|
async function loadSelectedPassage() {
|
||||||
const source = getSelectedSource();
|
const source = getSelectedSource();
|
||||||
const work = getSelectedWork(source);
|
const work = getSelectedWork(source);
|
||||||
const section = getSelectedSection(source, work);
|
const section = getSelectedSection(source, work);
|
||||||
|
const compareSource = isCompareModeEnabled(getSelectedSourceGroup()) ? getCompareSource() : null;
|
||||||
if (!source || !work || !section) {
|
if (!source || !work || !section) {
|
||||||
state.currentPassage = null;
|
state.currentPassage = null;
|
||||||
|
state.comparePassage = null;
|
||||||
renderDetail();
|
renderDetail();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
state.currentPassage = null;
|
state.currentPassage = null;
|
||||||
|
state.comparePassage = null;
|
||||||
renderDetail();
|
renderDetail();
|
||||||
|
|
||||||
try {
|
const [primaryResult, compareResult] = await Promise.allSettled([
|
||||||
state.currentPassage = await dataService.loadTextSection?.(source.id, work.id, section.id);
|
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();
|
renderDetail();
|
||||||
if (state.highlightedVerseId) {
|
if (state.highlightedVerseId) {
|
||||||
requestAnimationFrame(scrollHighlightedVerseIntoView);
|
requestAnimationFrame(scrollHighlightedVerseIntoView);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
state.currentPassage = {
|
|
||||||
source,
|
|
||||||
work,
|
|
||||||
section,
|
|
||||||
verses: [],
|
|
||||||
errorMessage: error?.message || "Unable to load this section."
|
|
||||||
};
|
|
||||||
renderDetail();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runSearch(scope, forceRefresh = false) {
|
async function runSearch(scope, forceRefresh = false) {
|
||||||
@@ -1586,7 +1986,10 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sourceGroup = findSourceGroupBySourceId(result.sourceId);
|
||||||
|
state.selectedSourceGroupId = sourceGroup?.id || "";
|
||||||
state.selectedSourceId = result.sourceId;
|
state.selectedSourceId = result.sourceId;
|
||||||
|
rememberSelectedSource(sourceGroup, result.sourceId);
|
||||||
state.selectedWorkId = result.workId;
|
state.selectedWorkId = result.workId;
|
||||||
state.selectedSectionId = result.sectionId;
|
state.selectedSectionId = result.sectionId;
|
||||||
state.highlightedVerseId = result.verseId;
|
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) {
|
if (workSelectEl) {
|
||||||
workSelectEl.addEventListener("change", () => {
|
workSelectEl.addEventListener("change", () => {
|
||||||
state.selectedWorkId = String(workSelectEl.value || "");
|
state.selectedWorkId = String(workSelectEl.value || "");
|
||||||
const source = getSelectedSource();
|
const source = getSelectedSource();
|
||||||
syncSelectionForSource(source);
|
syncSelectionForSource(source);
|
||||||
state.currentPassage = null;
|
state.currentPassage = null;
|
||||||
|
state.comparePassage = null;
|
||||||
state.lexiconEntry = null;
|
state.lexiconEntry = null;
|
||||||
state.highlightedVerseId = "";
|
state.highlightedVerseId = "";
|
||||||
renderSelectors();
|
renderSelectors();
|
||||||
@@ -1658,6 +2107,7 @@
|
|||||||
sectionSelectEl.addEventListener("change", () => {
|
sectionSelectEl.addEventListener("change", () => {
|
||||||
state.selectedSectionId = String(sectionSelectEl.value || "");
|
state.selectedSectionId = String(sectionSelectEl.value || "");
|
||||||
state.currentPassage = null;
|
state.currentPassage = null;
|
||||||
|
state.comparePassage = null;
|
||||||
state.lexiconEntry = null;
|
state.lexiconEntry = null;
|
||||||
state.highlightedVerseId = "";
|
state.highlightedVerseId = "";
|
||||||
void loadSelectedPassage();
|
void loadSelectedPassage();
|
||||||
@@ -1699,8 +2149,13 @@
|
|||||||
function resetState() {
|
function resetState() {
|
||||||
state.catalog = null;
|
state.catalog = null;
|
||||||
state.currentPassage = null;
|
state.currentPassage = null;
|
||||||
|
state.comparePassage = null;
|
||||||
state.lexiconEntry = null;
|
state.lexiconEntry = null;
|
||||||
|
state.selectedSourceGroupId = "";
|
||||||
state.selectedSourceId = "";
|
state.selectedSourceId = "";
|
||||||
|
state.selectedSourceIdByGroup = {};
|
||||||
|
state.compareSourceIdByGroup = {};
|
||||||
|
state.compareModeByGroup = {};
|
||||||
state.selectedWorkId = "";
|
state.selectedWorkId = "";
|
||||||
state.selectedSectionId = "";
|
state.selectedSectionId = "";
|
||||||
state.lexiconRequestId = 0;
|
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));
|
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) {
|
function scheduleAutoCollapse(layout) {
|
||||||
if (!(layout instanceof HTMLElement)) {
|
if (!(layout instanceof HTMLElement)) {
|
||||||
return;
|
return;
|
||||||
@@ -151,46 +173,35 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function initializeSidebarAutoCollapse() {
|
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)) {
|
if (!(layout instanceof HTMLElement)) {
|
||||||
return;
|
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")
|
|
||||||
));
|
|
||||||
|
|
||||||
if (!(panel instanceof HTMLElement) || panel.dataset.sidebarAutoCollapseReady === "1") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
panel.dataset.sidebarAutoCollapseReady = "1";
|
|
||||||
|
|
||||||
panel.addEventListener("click", (event) => {
|
|
||||||
const target = event.target instanceof Element ? event.target : null;
|
|
||||||
if (!shouldAutoCollapseFromEvent(panel, target)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduleAutoCollapse(layout);
|
scheduleAutoCollapse(layout);
|
||||||
});
|
}, true);
|
||||||
|
|
||||||
panel.addEventListener("keydown", (event) => {
|
document.addEventListener("keydown", (event) => {
|
||||||
if (event.key !== "Enter" && event.key !== " ") {
|
if (event.key !== "Enter" && event.key !== " ") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = event.target instanceof Element ? event.target : null;
|
const target = event.target instanceof Element ? event.target : null;
|
||||||
if (!shouldAutoCollapseFromEvent(panel, target)) {
|
const layout = getAutoCollapseLayoutFromTarget(target);
|
||||||
|
if (!(layout instanceof HTMLElement)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
scheduleAutoCollapse(layout);
|
scheduleAutoCollapse(layout);
|
||||||
});
|
}, true);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function initializeSidebarPopouts() {
|
function initializeSidebarPopouts() {
|
||||||
@@ -361,6 +372,7 @@
|
|||||||
|
|
||||||
const nextOpen = Boolean(isOpen);
|
const nextOpen = Boolean(isOpen);
|
||||||
topbarEl.classList.toggle("is-menu-open", nextOpen);
|
topbarEl.classList.toggle("is-menu-open", nextOpen);
|
||||||
|
document.body.classList.toggle("topbar-menu-open", nextOpen);
|
||||||
menuToggleEl.setAttribute("aria-expanded", nextOpen ? "true" : "false");
|
menuToggleEl.setAttribute("aria-expanded", nextOpen ? "true" : "false");
|
||||||
menuToggleEl.textContent = nextOpen ? "Close" : "Menu";
|
menuToggleEl.textContent = nextOpen ? "Close" : "Menu";
|
||||||
menuToggleEl.setAttribute("aria-label", nextOpen ? "Close navigation menu" : "Open navigation menu");
|
menuToggleEl.setAttribute("aria-label", nextOpen ? "Close navigation menu" : "Open navigation menu");
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
let config = {};
|
let config = {};
|
||||||
let lastNowSkyGeoKey = "";
|
let lastNowSkyGeoKey = "";
|
||||||
let lastNowSkySourceUrl = "";
|
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";
|
const NOW_SKY_FOV_DEGREES = "220";
|
||||||
|
|
||||||
function getNowSkyLayerEl() {
|
function getNowSkyLayerEl() {
|
||||||
|
|||||||
@@ -37,6 +37,20 @@
|
|||||||
setActiveSection("home");
|
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, () => {
|
bindClick(elements.openTarotEl, () => {
|
||||||
if (getActiveSection() === "tarot") {
|
if (getActiveSection() === "tarot") {
|
||||||
setActiveSection("home");
|
setActiveSection("home");
|
||||||
|
|||||||
@@ -24,6 +24,21 @@
|
|||||||
{ id: "pluto", astronomyBody: "Pluto", fallbackName: "Pluto", fallbackSymbol: "♇︎" }
|
{ 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() {
|
function resetNowLightboxZoom() {
|
||||||
if (!nowLightboxImageEl) {
|
if (!nowLightboxImageEl) {
|
||||||
return;
|
return;
|
||||||
@@ -302,22 +317,94 @@
|
|||||||
return positions;
|
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) {
|
function updateNowStats(referenceData, elements, now) {
|
||||||
const planetPositions = calculatePlanetPositions(referenceData, now);
|
const planetPositions = calculatePlanetPositions(referenceData, now);
|
||||||
|
|
||||||
if (elements.nowStatsPlanetsEl) {
|
if (elements.nowStatsPlanetsEl) {
|
||||||
elements.nowStatsPlanetsEl.replaceChildren();
|
renderNowPlanetPositions(elements.nowStatsPlanetsEl, planetPositions);
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (elements.nowStatsSabianEl) {
|
if (elements.nowStatsSabianEl) {
|
||||||
@@ -524,6 +611,7 @@
|
|||||||
formatCountdown,
|
formatCountdown,
|
||||||
getDisplayTarotName,
|
getDisplayTarotName,
|
||||||
setNowCardImage,
|
setNowCardImage,
|
||||||
|
renderNowPlanetPositions,
|
||||||
updateNowStats
|
updateNowStats
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
|| typeof nowUiHelpers.getSignStartDate !== "function"
|
|| typeof nowUiHelpers.getSignStartDate !== "function"
|
||||||
|| typeof nowUiHelpers.getDisplayTarotName !== "function"
|
|| typeof nowUiHelpers.getDisplayTarotName !== "function"
|
||||||
|| typeof nowUiHelpers.setNowCardImage !== "function"
|
|| typeof nowUiHelpers.setNowCardImage !== "function"
|
||||||
|
|| typeof nowUiHelpers.renderNowPlanetPositions !== "function"
|
||||||
|| typeof nowUiHelpers.updateNowStats !== "function"
|
|| typeof nowUiHelpers.updateNowStats !== "function"
|
||||||
) {
|
) {
|
||||||
throw new Error("NowUiHelpers module must load before ui-now.js");
|
throw new Error("NowUiHelpers module must load before ui-now.js");
|
||||||
@@ -41,19 +42,10 @@
|
|||||||
|
|
||||||
function renderNowStatsFromSnapshot(elements, stats) {
|
function renderNowStatsFromSnapshot(elements, stats) {
|
||||||
if (elements.nowStatsPlanetsEl) {
|
if (elements.nowStatsPlanetsEl) {
|
||||||
elements.nowStatsPlanetsEl.replaceChildren();
|
nowUiHelpers.renderNowPlanetPositions(
|
||||||
|
elements.nowStatsPlanetsEl,
|
||||||
const planetPositions = Array.isArray(stats?.planetPositions) ? stats.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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (elements.nowStatsSabianEl) {
|
if (elements.nowStatsSabianEl) {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
"timeline",
|
"timeline",
|
||||||
"calendar",
|
"calendar",
|
||||||
"holidays",
|
"holidays",
|
||||||
|
"audio-circle",
|
||||||
|
"audio-notes",
|
||||||
"tarot",
|
"tarot",
|
||||||
"tarot-house",
|
"tarot-house",
|
||||||
"astronomy",
|
"astronomy",
|
||||||
@@ -88,6 +90,9 @@
|
|||||||
const isCalendarOpen = activeSection === "calendar";
|
const isCalendarOpen = activeSection === "calendar";
|
||||||
const isHolidaysOpen = activeSection === "holidays";
|
const isHolidaysOpen = activeSection === "holidays";
|
||||||
const isCalendarMenuOpen = isTimelineOpen || isCalendarOpen || isHolidaysOpen;
|
const isCalendarMenuOpen = isTimelineOpen || isCalendarOpen || isHolidaysOpen;
|
||||||
|
const isAudioNotesOpen = activeSection === "audio-notes";
|
||||||
|
const isAudioCircleOpen = activeSection === "audio-circle";
|
||||||
|
const isAudioMenuOpen = isAudioNotesOpen || isAudioCircleOpen;
|
||||||
const isTarotOpen = activeSection === "tarot";
|
const isTarotOpen = activeSection === "tarot";
|
||||||
const isTarotHouseOpen = activeSection === "tarot-house";
|
const isTarotHouseOpen = activeSection === "tarot-house";
|
||||||
const isTarotMenuOpen = isTarotOpen || isTarotHouseOpen;
|
const isTarotMenuOpen = isTarotOpen || isTarotHouseOpen;
|
||||||
@@ -115,6 +120,8 @@
|
|||||||
setHidden(elements.timelineSectionEl, !isTimelineOpen);
|
setHidden(elements.timelineSectionEl, !isTimelineOpen);
|
||||||
setHidden(elements.calendarSectionEl, !isCalendarOpen);
|
setHidden(elements.calendarSectionEl, !isCalendarOpen);
|
||||||
setHidden(elements.holidaySectionEl, !isHolidaysOpen);
|
setHidden(elements.holidaySectionEl, !isHolidaysOpen);
|
||||||
|
setHidden(elements.audioCircleSectionEl, !isAudioCircleOpen);
|
||||||
|
setHidden(elements.audioNotesSectionEl, !isAudioNotesOpen);
|
||||||
setHidden(elements.tarotSectionEl, !isTarotOpen);
|
setHidden(elements.tarotSectionEl, !isTarotOpen);
|
||||||
setHidden(elements.tarotHouseSectionEl, !isTarotHouseOpen);
|
setHidden(elements.tarotHouseSectionEl, !isTarotHouseOpen);
|
||||||
setHidden(elements.astronomySectionEl, !isAstronomyOpen);
|
setHidden(elements.astronomySectionEl, !isAstronomyOpen);
|
||||||
@@ -141,6 +148,9 @@
|
|||||||
toggleActive(elements.openCalendarTimelineEl, isTimelineOpen);
|
toggleActive(elements.openCalendarTimelineEl, isTimelineOpen);
|
||||||
toggleActive(elements.openCalendarMonthsEl, isCalendarOpen);
|
toggleActive(elements.openCalendarMonthsEl, isCalendarOpen);
|
||||||
toggleActive(elements.openHolidaysEl, isHolidaysOpen);
|
toggleActive(elements.openHolidaysEl, isHolidaysOpen);
|
||||||
|
setPressed(elements.openAudioEl, isAudioMenuOpen);
|
||||||
|
toggleActive(elements.openAudioCircleEl, isAudioCircleOpen);
|
||||||
|
toggleActive(elements.openAudioNotesEl, isAudioNotesOpen);
|
||||||
setPressed(elements.openTarotEl, isTarotMenuOpen);
|
setPressed(elements.openTarotEl, isTarotMenuOpen);
|
||||||
toggleActive(elements.openTarotHouseEl, isTarotHouseOpen);
|
toggleActive(elements.openTarotHouseEl, isTarotHouseOpen);
|
||||||
config.tarotSpreadUi?.applyViewState?.();
|
config.tarotSpreadUi?.applyViewState?.();
|
||||||
@@ -181,6 +191,16 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isAudioCircleOpen) {
|
||||||
|
ensure.ensureAudioCircleSection?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAudioNotesOpen) {
|
||||||
|
ensure.ensureAudioNotesSection?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (isTarotOpen) {
|
if (isTarotOpen) {
|
||||||
if (typeof config.tarotSpreadUi?.handleSectionActivated === "function") {
|
if (typeof config.tarotSpreadUi?.handleSectionActivated === "function") {
|
||||||
config.tarotSpreadUi.handleSectionActivated();
|
config.tarotSpreadUi.handleSectionActivated();
|
||||||
|
|||||||
81
index.html
81
index.html
@@ -16,7 +16,7 @@
|
|||||||
<link rel="stylesheet" href="node_modules/@fontsource/amiri/arabic-400.css">
|
<link rel="stylesheet" href="node_modules/@fontsource/amiri/arabic-400.css">
|
||||||
<link rel="stylesheet" href="node_modules/@fontsource/amiri/arabic-700.css">
|
<link rel="stylesheet" href="node_modules/@fontsource/amiri/arabic-700.css">
|
||||||
<link rel="stylesheet" href="node_modules/@fontsource/noto-naskh-arabic/arabic-400.css">
|
<link rel="stylesheet" href="node_modules/@fontsource/noto-naskh-arabic/arabic-400.css">
|
||||||
<link rel="stylesheet" href="app/styles.css?v=20260312-house-cube-01">
|
<link rel="stylesheet" href="app/styles.css?v=20260314-audio-circle-01">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
@@ -39,6 +39,13 @@
|
|||||||
<button id="open-zodiac" class="settings-trigger topbar-sub-trigger" type="button" role="menuitem">Zodiac</button>
|
<button id="open-zodiac" class="settings-trigger topbar-sub-trigger" type="button" role="menuitem">Zodiac</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="topbar-dropdown" aria-label="Audio menu">
|
||||||
|
<button id="open-audio" class="settings-trigger" type="button" aria-pressed="false" aria-haspopup="menu" aria-controls="audio-subpages" aria-expanded="false">Audio ▾</button>
|
||||||
|
<div id="audio-subpages" class="topbar-dropdown-menu" role="menu" aria-label="Audio subpages">
|
||||||
|
<button id="open-audio-circle" class="settings-trigger topbar-sub-trigger" type="button" role="menuitem">Circle</button>
|
||||||
|
<button id="open-audio-notes" class="settings-trigger topbar-sub-trigger" type="button" role="menuitem">Notes</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="topbar-dropdown" aria-label="Calendar menu">
|
<div class="topbar-dropdown" aria-label="Calendar menu">
|
||||||
<button id="open-calendar" class="settings-trigger" type="button" aria-pressed="false" aria-haspopup="menu" aria-controls="calendar-subpages" aria-expanded="false">Calendar ▾</button>
|
<button id="open-calendar" class="settings-trigger" type="button" aria-pressed="false" aria-haspopup="menu" aria-controls="calendar-subpages" aria-expanded="false">Calendar ▾</button>
|
||||||
<div id="calendar-subpages" class="topbar-dropdown-menu" role="menu" aria-label="Calendar subpages">
|
<div id="calendar-subpages" class="topbar-dropdown-menu" role="menu" aria-label="Calendar subpages">
|
||||||
@@ -455,6 +462,50 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<section id="audio-notes-section" hidden>
|
||||||
|
<div class="planet-layout">
|
||||||
|
<aside class="planet-list-panel">
|
||||||
|
<div class="planet-list-header">
|
||||||
|
<strong>Audio > Notes</strong>
|
||||||
|
<span id="audio-note-count" class="planet-list-count">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="audio-note-intro planet-text">Browse the 12 equal-tempered pitch classes with reference frequencies anchored to A4 = 440 Hz.</div>
|
||||||
|
<div class="dataset-search-wrap">
|
||||||
|
<input id="audio-note-search-input" class="dataset-search-input" type="search" placeholder="Search note names, aliases, intervals" aria-label="Search audio notes">
|
||||||
|
<button id="audio-note-search-clear" class="dataset-search-clear" type="button" aria-label="Clear audio note search" disabled>×</button>
|
||||||
|
</div>
|
||||||
|
<div id="audio-note-list" class="planet-card-list" role="listbox" aria-label="Audio notes"></div>
|
||||||
|
</aside>
|
||||||
|
<section class="planet-detail-panel" aria-live="polite">
|
||||||
|
<div class="planet-detail-heading">
|
||||||
|
<h2 id="audio-note-detail-name">--</h2>
|
||||||
|
<div id="audio-note-detail-sub" class="planet-detail-type">Select a note to explore</div>
|
||||||
|
<div id="audio-note-detail-summary" class="planet-detail-summary">--</div>
|
||||||
|
</div>
|
||||||
|
<div id="audio-note-detail-body" class="audio-note-detail-stack"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section id="audio-circle-section" hidden>
|
||||||
|
<div class="audio-circle-layout">
|
||||||
|
<aside class="audio-circle-stage-panel">
|
||||||
|
<div class="planet-list-header">
|
||||||
|
<strong>Audio > Circle</strong>
|
||||||
|
<span class="planet-list-count">12 Keys</span>
|
||||||
|
</div>
|
||||||
|
<div class="audio-circle-intro planet-text">The Circle of Fifths orders keys by perfect fifth motion. Tap any key to hear its tonic and inspect its clockwise and counter-clockwise neighbors.</div>
|
||||||
|
<div id="audio-circle-stage" class="audio-circle-stage" aria-label="Circle of Fifths"></div>
|
||||||
|
</aside>
|
||||||
|
<section class="planet-detail-panel audio-circle-detail-panel" aria-live="polite">
|
||||||
|
<div class="planet-detail-heading">
|
||||||
|
<h2 id="audio-circle-detail-name">Circle of Fifths</h2>
|
||||||
|
<div id="audio-circle-detail-sub" class="planet-detail-type">Select a key to explore its tonic, relative minor, and neighbors</div>
|
||||||
|
<div id="audio-circle-detail-summary" class="planet-detail-summary">--</div>
|
||||||
|
</div>
|
||||||
|
<div id="audio-circle-detail-body" class="audio-circle-detail-stack"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<section id="iching-section" hidden>
|
<section id="iching-section" hidden>
|
||||||
<div class="planet-layout">
|
<div class="planet-layout">
|
||||||
<aside class="planet-list-panel">
|
<aside class="planet-list-panel">
|
||||||
@@ -737,6 +788,18 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="alpha-text-heading-tools">
|
<div class="alpha-text-heading-tools">
|
||||||
<div class="alpha-text-controls alpha-text-controls--heading">
|
<div class="alpha-text-controls alpha-text-controls--heading">
|
||||||
|
<label class="alpha-text-control" for="alpha-text-translation-select" hidden>
|
||||||
|
<span>Translation</span>
|
||||||
|
<select id="alpha-text-translation-select" class="alpha-text-select" aria-label="Select text translation"></select>
|
||||||
|
</label>
|
||||||
|
<div id="alpha-text-compare-toggle-control" class="alpha-text-control alpha-text-control--toggle" hidden>
|
||||||
|
<span>Compare</span>
|
||||||
|
<button id="alpha-text-compare-toggle" class="alpha-nav-btn alpha-text-compare-toggle" type="button" aria-pressed="false">Off</button>
|
||||||
|
</div>
|
||||||
|
<label class="alpha-text-control" for="alpha-text-compare-select" hidden>
|
||||||
|
<span>Compare With</span>
|
||||||
|
<select id="alpha-text-compare-select" class="alpha-text-select" aria-label="Select comparison translation"></select>
|
||||||
|
</label>
|
||||||
<label class="alpha-text-control" for="alpha-text-work-select">
|
<label class="alpha-text-control" for="alpha-text-work-select">
|
||||||
<span>Work</span>
|
<span>Work</span>
|
||||||
<select id="alpha-text-work-select" class="alpha-text-select" aria-label="Select text work"></select>
|
<select id="alpha-text-work-select" class="alpha-text-select" aria-label="Select text work"></select>
|
||||||
@@ -965,8 +1028,8 @@
|
|||||||
<script src="app/ui-tarot-lightbox.js?v=20260312-compare-zoom-01"></script>
|
<script src="app/ui-tarot-lightbox.js?v=20260312-compare-zoom-01"></script>
|
||||||
<script src="app/ui-tarot-house.js?v=20260312-house-cube-01"></script>
|
<script src="app/ui-tarot-house.js?v=20260312-house-cube-01"></script>
|
||||||
<script src="app/ui-tarot-relations.js"></script>
|
<script src="app/ui-tarot-relations.js"></script>
|
||||||
<script src="app/ui-now-helpers.js"></script>
|
<script src="app/ui-now-helpers.js?v=20260314-now-planets-grid-01"></script>
|
||||||
<script src="app/ui-now.js"></script>
|
<script src="app/ui-now.js?v=20260314-now-planets-grid-01"></script>
|
||||||
<script src="app/ui-natal.js"></script>
|
<script src="app/ui-natal.js"></script>
|
||||||
<script src="app/tarot-database-builders.js"></script>
|
<script src="app/tarot-database-builders.js"></script>
|
||||||
<script src="app/tarot-database-assembly.js"></script>
|
<script src="app/tarot-database-assembly.js"></script>
|
||||||
@@ -987,6 +1050,8 @@
|
|||||||
<script src="app/ui-planets.js"></script>
|
<script src="app/ui-planets.js"></script>
|
||||||
<script src="app/ui-cycles.js"></script>
|
<script src="app/ui-cycles.js"></script>
|
||||||
<script src="app/ui-elements.js"></script>
|
<script src="app/ui-elements.js"></script>
|
||||||
|
<script src="app/ui-audio-notes.js?v=20260314-audio-notes-02"></script>
|
||||||
|
<script src="app/ui-audio-circle.js?v=20260314-audio-circle-01"></script>
|
||||||
<script src="app/ui-iching-references.js"></script>
|
<script src="app/ui-iching-references.js"></script>
|
||||||
<script src="app/ui-iching.js"></script>
|
<script src="app/ui-iching.js"></script>
|
||||||
<script src="app/ui-rosicrucian-cross.js"></script>
|
<script src="app/ui-rosicrucian-cross.js"></script>
|
||||||
@@ -1004,7 +1069,7 @@
|
|||||||
<script src="app/ui-alphabet-detail.js?v=20260309-enochian-api"></script>
|
<script src="app/ui-alphabet-detail.js?v=20260309-enochian-api"></script>
|
||||||
<script src="app/ui-alphabet-kabbalah.js"></script>
|
<script src="app/ui-alphabet-kabbalah.js"></script>
|
||||||
<script src="app/ui-alphabet.js?v=20260308b"></script>
|
<script src="app/ui-alphabet.js?v=20260308b"></script>
|
||||||
<script src="app/ui-alphabet-text.js?v=20260312-source-search-01"></script>
|
<script src="app/ui-alphabet-text.js?v=20260314-text-compare-01"></script>
|
||||||
<script src="app/ui-zodiac-references.js"></script>
|
<script src="app/ui-zodiac-references.js"></script>
|
||||||
<script src="app/ui-zodiac.js"></script>
|
<script src="app/ui-zodiac.js"></script>
|
||||||
<script src="app/ui-quiz-bank-builtins-domains.js"></script>
|
<script src="app/ui-quiz-bank-builtins-domains.js"></script>
|
||||||
@@ -1021,13 +1086,13 @@
|
|||||||
<script src="app/ui-numbers.js"></script>
|
<script src="app/ui-numbers.js"></script>
|
||||||
<script src="app/ui-tarot-spread.js"></script>
|
<script src="app/ui-tarot-spread.js"></script>
|
||||||
<script src="app/ui-settings.js?v=20260309-gate"></script>
|
<script src="app/ui-settings.js?v=20260309-gate"></script>
|
||||||
<script src="app/ui-chrome.js?v=20260312-menu-unify-01"></script>
|
<script src="app/ui-chrome.js?v=20260314-mobile-menu-overlay-01"></script>
|
||||||
<script src="app/ui-navigation.js?v=20260312-house-cube-01"></script>
|
<script src="app/ui-navigation.js?v=20260314-audio-circle-01"></script>
|
||||||
<script src="app/ui-calendar-formatting.js?v=20260307b"></script>
|
<script src="app/ui-calendar-formatting.js?v=20260307b"></script>
|
||||||
<script src="app/ui-calendar-visuals.js?v=20260307b"></script>
|
<script src="app/ui-calendar-visuals.js?v=20260307b"></script>
|
||||||
<script src="app/ui-home-calendar.js"></script>
|
<script src="app/ui-home-calendar.js"></script>
|
||||||
<script src="app/ui-section-state.js?v=20260312-house-cube-01"></script>
|
<script src="app/ui-section-state.js?v=20260314-audio-circle-01"></script>
|
||||||
<script src="app/app-runtime.js?v=20260309-gate"></script>
|
<script src="app/app-runtime.js?v=20260309-gate"></script>
|
||||||
<script src="app.js?v=20260312-house-cube-01"></script>
|
<script src="app.js?v=20260314-audio-circle-01"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user