Compare commits
4 Commits
240401b9cb
...
0e437d2b88
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e437d2b88 | ||
|
|
bcc6727917 | ||
|
|
af52f32330 | ||
|
|
d278e8998a |
@@ -2,11 +2,12 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" sizes="192x192" href="/icon-192.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
|
||||
<meta name="description" content="A diagnostic tool for the study of protocols" />
|
||||
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#000000" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="theme-color" content="#1a1a2e" />
|
||||
<title>Protocol Bicorder</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
Before Width: | Height: | Size: 11 B After Width: | Height: | Size: 5.3 KiB |
14
bicorder-app/public/favicon.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||
<defs>
|
||||
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#1a1a2e;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#16213e;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<rect width="128" height="128" fill="url(#bgGrad)" rx="16"/>
|
||||
|
||||
<!-- Large hash symbol -->
|
||||
<text x="64" y="95" font-family="'Courier New', monospace" font-size="100" font-weight="bold" fill="#bbe1fa" text-anchor="middle">#</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 577 B |
BIN
bicorder-app/public/icon-192.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
bicorder-app/public/icon-512.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
@@ -1,11 +1,47 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect width="100" height="100" fill="#ffffff"/>
|
||||
<text x="50" y="35" font-family="monospace" font-size="16" fill="#000000" text-anchor="middle">
|
||||
Protocol
|
||||
</text>
|
||||
<text x="50" y="55" font-family="monospace" font-size="20" fill="#000000" text-anchor="middle" font-weight="bold">
|
||||
BICORDER
|
||||
</text>
|
||||
<rect x="20" y="65" width="60" height="10" fill="none" stroke="#000000" stroke-width="2"/>
|
||||
<rect x="22" y="67" width="20" height="6" fill="#000000"/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#1a1a2e;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#16213e;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="accentGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#0f4c75;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#3282b8;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<rect width="512" height="512" fill="url(#bgGradient)" rx="80"/>
|
||||
|
||||
<!-- Outer frame -->
|
||||
<rect x="64" y="64" width="384" height="384" fill="none" stroke="url(#accentGradient)" stroke-width="8" rx="20"/>
|
||||
|
||||
<!-- Bicorder pattern "|#|" in monospace style -->
|
||||
<g font-family="'Courier New', monospace" font-weight="bold" text-anchor="middle">
|
||||
<!-- Left bar -->
|
||||
<text x="180" y="300" font-size="180" fill="#3282b8">|</text>
|
||||
|
||||
<!-- Center hash -->
|
||||
<text x="256" y="300" font-size="180" fill="#bbe1fa">#</text>
|
||||
|
||||
<!-- Right bar -->
|
||||
<text x="332" y="300" font-size="180" fill="#3282b8">|</text>
|
||||
</g>
|
||||
|
||||
<!-- Decorative scan lines -->
|
||||
<line x1="96" y1="140" x2="416" y2="140" stroke="#3282b8" stroke-width="2" opacity="0.3"/>
|
||||
<line x1="96" y1="372" x2="416" y2="372" stroke="#3282b8" stroke-width="2" opacity="0.3"/>
|
||||
|
||||
<!-- Corner accents -->
|
||||
<rect x="80" y="80" width="30" height="4" fill="#bbe1fa" opacity="0.8"/>
|
||||
<rect x="80" y="80" width="4" height="30" fill="#bbe1fa" opacity="0.8"/>
|
||||
|
||||
<rect x="402" y="80" width="30" height="4" fill="#bbe1fa" opacity="0.8"/>
|
||||
<rect x="428" y="80" width="4" height="30" fill="#bbe1fa" opacity="0.8"/>
|
||||
|
||||
<rect x="80" y="428" width="30" height="4" fill="#bbe1fa" opacity="0.8"/>
|
||||
<rect x="80" y="402" width="4" height="30" fill="#bbe1fa" opacity="0.8"/>
|
||||
|
||||
<rect x="402" y="428" width="30" height="4" fill="#bbe1fa" opacity="0.8"/>
|
||||
<rect x="428" y="402" width="4" height="30" fill="#bbe1fa" opacity="0.8"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 535 B After Width: | Height: | Size: 2.0 KiB |
@@ -14,6 +14,86 @@
|
||||
data.metadata.timestamp = new Date().toISOString();
|
||||
}
|
||||
|
||||
// View mode and navigation state
|
||||
type ViewMode = 'focused' | 'list';
|
||||
let viewMode: ViewMode = 'focused'; // Focused is default
|
||||
let currentScreen = 0;
|
||||
let refreshKey = 0; // Used to force component refresh in focused mode
|
||||
|
||||
// Screen types
|
||||
type Screen =
|
||||
| { type: 'metadata' }
|
||||
| { type: 'gradient'; setIndex: number; gradientIndex: number; gradient: Gradient; setName: string }
|
||||
| { type: 'analysis'; index: number; gradient: AnalysisGradient }
|
||||
| { type: 'export' };
|
||||
|
||||
// Calculate all screens based on current shortform setting
|
||||
function calculateScreens(): Screen[] {
|
||||
const screens: Screen[] = [];
|
||||
|
||||
// Metadata screen
|
||||
screens.push({ type: 'metadata' });
|
||||
|
||||
// Diagnostic gradient screens
|
||||
data.diagnostic.forEach((diagnosticSet, setIndex) => {
|
||||
diagnosticSet.gradients.forEach((gradient, gradientIndex) => {
|
||||
if (!data.metadata.shortform || gradient.shortform) {
|
||||
screens.push({
|
||||
type: 'gradient',
|
||||
setIndex,
|
||||
gradientIndex,
|
||||
gradient,
|
||||
setName: diagnosticSet.set_name
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Analysis screens (not in shortform)
|
||||
if (!data.metadata.shortform) {
|
||||
data.analysis.forEach((gradient, index) => {
|
||||
screens.push({ type: 'analysis', index, gradient });
|
||||
});
|
||||
}
|
||||
|
||||
// Export screen
|
||||
screens.push({ type: 'export' });
|
||||
|
||||
return screens;
|
||||
}
|
||||
|
||||
$: screens = calculateScreens();
|
||||
$: currentScreenData = screens[currentScreen];
|
||||
$: totalScreens = screens.length;
|
||||
|
||||
function goToNextScreen() {
|
||||
if (currentScreen < totalScreens - 1) {
|
||||
currentScreen++;
|
||||
}
|
||||
}
|
||||
|
||||
function goToPrevScreen() {
|
||||
if (currentScreen > 0) {
|
||||
currentScreen--;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleViewMode() {
|
||||
viewMode = viewMode === 'focused' ? 'list' : 'focused';
|
||||
}
|
||||
|
||||
// Generate ASCII progress bar
|
||||
function generateProgressBar(current: number, total: number): string {
|
||||
const filled = '#';
|
||||
const empty = '-';
|
||||
const barLength = Math.min(total, 20); // Cap at 20 characters for display
|
||||
const filledCount = Math.round((current / total) * barLength);
|
||||
const emptyCount = barLength - filledCount;
|
||||
return filled.repeat(filledCount) + empty.repeat(emptyCount);
|
||||
}
|
||||
|
||||
$: progressBar = generateProgressBar(currentScreen + 1, totalScreens);
|
||||
|
||||
// Load saved state from localStorage
|
||||
onMount(() => {
|
||||
const saved = localStorage.getItem('bicorder-state');
|
||||
@@ -45,6 +125,27 @@
|
||||
console.error('Failed to load saved state:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard navigation for focused mode
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (viewMode !== 'focused') return;
|
||||
|
||||
// Only navigate if not typing in an input
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
goToNextScreen();
|
||||
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
goToPrevScreen();
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
});
|
||||
|
||||
// Save state whenever it changes
|
||||
@@ -117,57 +218,181 @@
|
||||
<div class="header">
|
||||
<div class="title">Protocol</div>
|
||||
<div class="title">BICORDER</div>
|
||||
<button class="mode-toggle" on:click={toggleViewMode} aria-label="Toggle view mode">
|
||||
{viewMode === 'focused' ? '☰' : '⊡'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<MetadataFields
|
||||
metadata={data.metadata}
|
||||
on:update={handleMetadataUpdate}
|
||||
/>
|
||||
{#if viewMode === 'list'}
|
||||
<!-- LIST MODE: Show all sections -->
|
||||
<MetadataFields
|
||||
metadata={data.metadata}
|
||||
on:update={handleMetadataUpdate}
|
||||
/>
|
||||
|
||||
{#each data.diagnostic as diagnosticSet, setIndex}
|
||||
<section class="diagnostic-set">
|
||||
<div class="set-header">{diagnosticSet.set_name.toUpperCase()}</div>
|
||||
<div class="set-description">{diagnosticSet.set_description}</div>
|
||||
{#each data.diagnostic as diagnosticSet, setIndex}
|
||||
<section class="diagnostic-set">
|
||||
<div class="set-header">{diagnosticSet.set_name.toUpperCase()}</div>
|
||||
<div class="set-description">{diagnosticSet.set_description}</div>
|
||||
|
||||
{#each diagnosticSet.gradients as gradient, gradientIndex}
|
||||
{#if !data.metadata.shortform || gradient.shortform}
|
||||
<GradientSlider
|
||||
{gradient}
|
||||
on:change={(e) => {
|
||||
data.diagnostic[setIndex].gradients[gradientIndex].value = e.detail ?? null;
|
||||
{#each diagnosticSet.gradients as gradient, gradientIndex}
|
||||
{#if !data.metadata.shortform || gradient.shortform}
|
||||
<GradientSlider
|
||||
{gradient}
|
||||
on:change={(e) => {
|
||||
data.diagnostic[setIndex].gradients[gradientIndex].value = e.detail ?? null;
|
||||
data = data;
|
||||
}}
|
||||
on:notes={(e) => {
|
||||
data.diagnostic[setIndex].gradients[gradientIndex].notes = e.detail;
|
||||
data = data;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
</section>
|
||||
{/each}
|
||||
|
||||
<section class="analysis-section">
|
||||
<div class="set-header">ANALYSIS</div>
|
||||
|
||||
{#each data.analysis as analysisItem, index}
|
||||
<AnalysisDisplay
|
||||
gradient={analysisItem}
|
||||
on:change={(e) => {
|
||||
if (!analysisItem.automated) {
|
||||
data.analysis[index].value = e.detail ?? null;
|
||||
data = data;
|
||||
}}
|
||||
on:notes={(e) => {
|
||||
data.diagnostic[setIndex].gradients[gradientIndex].notes = e.detail;
|
||||
data = data;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
}
|
||||
}}
|
||||
on:notes={(e) => {
|
||||
data.analysis[index].notes = e.detail;
|
||||
data = data;
|
||||
}}
|
||||
/>
|
||||
{/each}
|
||||
</section>
|
||||
{/each}
|
||||
|
||||
<section class="analysis-section">
|
||||
<div class="set-header">ANALYSIS</div>
|
||||
<ExportControls {data} on:reset={handleReset} />
|
||||
|
||||
{#each data.analysis as analysisItem, index}
|
||||
<AnalysisDisplay
|
||||
gradient={analysisItem}
|
||||
on:change={(e) => {
|
||||
if (!analysisItem.automated) {
|
||||
data.analysis[index].value = e.detail ?? null;
|
||||
data = data;
|
||||
}
|
||||
}}
|
||||
on:notes={(e) => {
|
||||
data.analysis[index].notes = e.detail;
|
||||
data = data;
|
||||
}}
|
||||
/>
|
||||
{/each}
|
||||
</section>
|
||||
{:else}
|
||||
<!-- FOCUSED MODE: Show one screen at a time -->
|
||||
<div class="focused-container">
|
||||
{#if currentScreenData.type === 'metadata'}
|
||||
<div class="focused-screen">
|
||||
<MetadataFields
|
||||
metadata={data.metadata}
|
||||
on:update={handleMetadataUpdate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ExportControls {data} on:reset={handleReset} />
|
||||
{:else if currentScreenData.type === 'gradient'}
|
||||
{@const screen = currentScreenData}
|
||||
<div class="focused-screen gradient-screen">
|
||||
<div class="screen-category">{screen.setName.toUpperCase()}</div>
|
||||
|
||||
<div class="gradient-focused">
|
||||
<div class="term-desc left-desc">
|
||||
<div class="term-name">{screen.gradient.term_left}</div>
|
||||
<div class="description-text">{screen.gradient.term_left_description}</div>
|
||||
</div>
|
||||
|
||||
{#key refreshKey}
|
||||
<GradientSlider
|
||||
gradient={screen.gradient}
|
||||
focusedMode={true}
|
||||
on:change={(e) => {
|
||||
data.diagnostic[screen.setIndex].gradients[screen.gradientIndex].value = e.detail ?? null;
|
||||
data = data;
|
||||
refreshKey++; // Force component refresh
|
||||
}}
|
||||
on:notes={(e) => {
|
||||
data.diagnostic[screen.setIndex].gradients[screen.gradientIndex].notes = e.detail;
|
||||
data = data;
|
||||
refreshKey++; // Force component refresh
|
||||
}}
|
||||
/>
|
||||
{/key}
|
||||
|
||||
<div class="term-desc right-desc">
|
||||
<div class="term-name">{screen.gradient.term_right}</div>
|
||||
<div class="description-text">{screen.gradient.term_right_description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if currentScreenData.type === 'analysis'}
|
||||
{@const screen = currentScreenData}
|
||||
<div class="focused-screen gradient-screen">
|
||||
<div class="screen-category">ANALYSIS</div>
|
||||
|
||||
<div class="gradient-focused">
|
||||
<div class="term-desc left-desc">
|
||||
<div class="term-name">{screen.gradient.term_left}</div>
|
||||
<div class="description-text">{screen.gradient.term_left_description}</div>
|
||||
</div>
|
||||
|
||||
{#key refreshKey}
|
||||
<AnalysisDisplay
|
||||
gradient={screen.gradient}
|
||||
focusedMode={true}
|
||||
on:change={(e) => {
|
||||
if (!screen.gradient.automated) {
|
||||
data.analysis[screen.index].value = e.detail ?? null;
|
||||
data = data;
|
||||
refreshKey++; // Force component refresh
|
||||
}
|
||||
}}
|
||||
on:notes={(e) => {
|
||||
data.analysis[screen.index].notes = e.detail;
|
||||
data = data;
|
||||
refreshKey++; // Force component refresh
|
||||
}}
|
||||
/>
|
||||
{/key}
|
||||
|
||||
<div class="term-desc right-desc">
|
||||
<div class="term-name">{screen.gradient.term_right}</div>
|
||||
<div class="description-text">{screen.gradient.term_right_description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if currentScreenData.type === 'export'}
|
||||
<div class="focused-screen">
|
||||
<ExportControls {data} focusedMode={true} on:reset={handleReset} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Navigation and Progress -->
|
||||
<div class="focused-navigation" class:no-border={currentScreenData.type === 'export'}>
|
||||
<div class="nav-buttons">
|
||||
<button
|
||||
class="nav-btn prev-btn"
|
||||
on:click={goToPrevScreen}
|
||||
disabled={currentScreen === 0}
|
||||
aria-label="Previous screen"
|
||||
>
|
||||
← Previous
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="nav-btn next-btn"
|
||||
on:click={goToNextScreen}
|
||||
disabled={currentScreen === totalScreens - 1}
|
||||
aria-label="Next screen"
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="progress-indicator">
|
||||
<div class="progress-bar">{progressBar}</div>
|
||||
<div class="progress-numbers">{currentScreen + 1} / {totalScreens}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
@@ -175,6 +400,9 @@
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
@@ -182,6 +410,7 @@
|
||||
margin-bottom: 2rem;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
padding-bottom: 1rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.title {
|
||||
@@ -190,6 +419,27 @@
|
||||
letter-spacing: 0.2rem;
|
||||
}
|
||||
|
||||
.mode-toggle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
padding: 0.3rem 0.6rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
background: var(--bg-color);
|
||||
color: var(--fg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
min-height: auto;
|
||||
opacity: 0.4;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.mode-toggle:hover {
|
||||
opacity: 0.8;
|
||||
background-color: var(--input-bg);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.diagnostic-set, .analysis-section {
|
||||
margin: 2rem 0;
|
||||
padding: 1rem 0;
|
||||
@@ -211,6 +461,135 @@
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Focused Mode Styles */
|
||||
.focused-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-height: 400px;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.focused-screen {
|
||||
animation: fadeIn 0.3s ease-in;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.gradient-screen {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.screen-category {
|
||||
text-align: center;
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
letter-spacing: 0.2rem;
|
||||
margin-bottom: 2rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.gradient-focused {
|
||||
padding: 2rem;
|
||||
border: 2px solid var(--border-color);
|
||||
background-color: var(--bg-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.term-desc {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.term-desc.left-desc {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.term-desc.right-desc {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.term-name {
|
||||
font-weight: bold;
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
color: var(--fg-color);
|
||||
}
|
||||
|
||||
.description-text {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
opacity: 0.8;
|
||||
color: var(--fg-color);
|
||||
}
|
||||
|
||||
.focused-navigation {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
padding: 1.5rem 0;
|
||||
margin-top: auto;
|
||||
border-top: 2px solid var(--border-color);
|
||||
}
|
||||
|
||||
.focused-navigation.no-border {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.nav-buttons {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.nav-btn:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.progress-indicator {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 1rem;
|
||||
letter-spacing: 0.1rem;
|
||||
font-weight: bold;
|
||||
color: var(--fg-color);
|
||||
}
|
||||
|
||||
.progress-numbers {
|
||||
font-size: 0.9rem;
|
||||
font-weight: bold;
|
||||
letter-spacing: 0.1rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
main {
|
||||
padding: 0.5rem;
|
||||
@@ -223,5 +602,34 @@
|
||||
.set-header {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.mode-toggle {
|
||||
font-size: 0.85rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.05rem;
|
||||
}
|
||||
|
||||
.progress-numbers {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.focused-container {
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.gradient-focused {
|
||||
padding: 1rem;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
min-width: 80px;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import Tooltip from './Tooltip.svelte';
|
||||
|
||||
export let gradient: AnalysisGradient;
|
||||
export let focusedMode: boolean = false;
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
change: number | null;
|
||||
@@ -98,12 +99,14 @@
|
||||
</script>
|
||||
|
||||
<div class="analysis-gradient">
|
||||
<div class="gradient-row">
|
||||
<div class="term left">
|
||||
<Tooltip text={gradient.term_left_description}>
|
||||
{gradient.term_left}
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div class="gradient-row" class:focused={focusedMode}>
|
||||
{#if !focusedMode}
|
||||
<div class="term left">
|
||||
<Tooltip text={gradient.term_left_description}>
|
||||
{gradient.term_left}
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="bar-container">
|
||||
<button
|
||||
@@ -124,11 +127,13 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="term right">
|
||||
<Tooltip text={gradient.term_right_description}>
|
||||
{gradient.term_right}
|
||||
</Tooltip>
|
||||
</div>
|
||||
{#if !focusedMode}
|
||||
<div class="term right">
|
||||
<Tooltip text={gradient.term_right_description}>
|
||||
{gradient.term_right}
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="value-display">
|
||||
@@ -200,6 +205,15 @@
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.gradient-row.focused {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.gradient-row.focused .bar-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.term {
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
@@ -257,6 +271,11 @@
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.analysis-gradient .value-display {
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.auto-label {
|
||||
opacity: 0.6;
|
||||
font-size: 0.8rem;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type { BicorderState } from '../types';
|
||||
|
||||
export let data: BicorderState;
|
||||
export let focusedMode: boolean = false;
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
reset: void;
|
||||
@@ -121,7 +122,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="export-controls">
|
||||
<section class="export-controls" class:focused={focusedMode}>
|
||||
<div class="button-group">
|
||||
<button on:click={exportToJSON}>
|
||||
💾 Export JSON
|
||||
@@ -170,6 +171,10 @@
|
||||
border-top: 2px solid var(--border-color);
|
||||
}
|
||||
|
||||
.export-controls.focused {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import Tooltip from './Tooltip.svelte';
|
||||
|
||||
export let gradient: Gradient;
|
||||
export let focusedMode: boolean = false;
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
change: number;
|
||||
@@ -94,12 +95,14 @@
|
||||
</script>
|
||||
|
||||
<div class="gradient-container">
|
||||
<div class="gradient-row">
|
||||
<div class="term left">
|
||||
<Tooltip text={gradient.term_left_description}>
|
||||
{gradient.term_left}
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div class="gradient-row" class:focused={focusedMode}>
|
||||
{#if !focusedMode}
|
||||
<div class="term left">
|
||||
<Tooltip text={gradient.term_left_description}>
|
||||
{gradient.term_left}
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="bar-container">
|
||||
<button
|
||||
@@ -117,11 +120,13 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="term right">
|
||||
<Tooltip text={gradient.term_right_description}>
|
||||
{gradient.term_right}
|
||||
</Tooltip>
|
||||
</div>
|
||||
{#if !focusedMode}
|
||||
<div class="term right">
|
||||
<Tooltip text={gradient.term_right_description}>
|
||||
{gradient.term_right}
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="value-display">
|
||||
@@ -179,6 +184,15 @@
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.gradient-row.focused {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.gradient-row.focused .bar-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.term {
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
@@ -231,6 +245,11 @@
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.gradient-container .value-display {
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.controls {
|
||||
margin-top: 0.5rem;
|
||||
display: flex;
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
|
||||
export let text: string;
|
||||
|
||||
let showTooltip = false;
|
||||
let touchTimer: number;
|
||||
let isLongPress = false;
|
||||
let tooltipElement: HTMLSpanElement;
|
||||
let wrapperElement: HTMLSpanElement;
|
||||
let tooltipPosition = { align: 'center', vertical: 'top' };
|
||||
|
||||
function handleTouchStart() {
|
||||
async function handleTouchStart() {
|
||||
isLongPress = false;
|
||||
touchTimer = window.setTimeout(() => {
|
||||
touchTimer = window.setTimeout(async () => {
|
||||
showTooltip = true;
|
||||
isLongPress = true;
|
||||
await tick();
|
||||
await updateTooltipPosition();
|
||||
}, 500); // Show after 500ms press
|
||||
}
|
||||
|
||||
@@ -23,12 +30,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
async function handleClick() {
|
||||
// Toggle tooltip on click/tap
|
||||
if (!isLongPress) {
|
||||
showTooltip = !showTooltip;
|
||||
// Auto-hide after 3 seconds
|
||||
if (showTooltip) {
|
||||
await tick();
|
||||
await updateTooltipPosition();
|
||||
setTimeout(() => {
|
||||
showTooltip = false;
|
||||
}, 3000);
|
||||
@@ -37,16 +46,59 @@
|
||||
isLongPress = false;
|
||||
}
|
||||
|
||||
function handleMouseEnter() {
|
||||
async function handleMouseEnter() {
|
||||
showTooltip = true;
|
||||
await tick();
|
||||
await updateTooltipPosition();
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
showTooltip = false;
|
||||
}
|
||||
|
||||
async function updateTooltipPosition() {
|
||||
// Wait for next frame to ensure rendering is complete
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
|
||||
if (!tooltipElement || !wrapperElement) return;
|
||||
|
||||
const wrapperRect = wrapperElement.getBoundingClientRect();
|
||||
const tooltipRect = tooltipElement.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const padding = 10;
|
||||
|
||||
// Calculate tooltip width and position
|
||||
const tooltipWidth = tooltipRect.width;
|
||||
const wrapperCenterX = wrapperRect.left + wrapperRect.width / 2;
|
||||
|
||||
// Determine horizontal alignment
|
||||
let align = 'center';
|
||||
|
||||
// Check if center alignment would overflow
|
||||
const centerLeft = wrapperCenterX - tooltipWidth / 2;
|
||||
const centerRight = wrapperCenterX + tooltipWidth / 2;
|
||||
|
||||
if (centerLeft < padding) {
|
||||
// Would overflow left, align to left edge of wrapper
|
||||
align = 'left';
|
||||
} else if (centerRight > viewportWidth - padding) {
|
||||
// Would overflow right, align to right edge of wrapper
|
||||
align = 'right';
|
||||
}
|
||||
|
||||
// Check vertical overflow
|
||||
let vertical = 'top';
|
||||
if (tooltipRect.top < padding) {
|
||||
vertical = 'bottom';
|
||||
}
|
||||
|
||||
tooltipPosition = { align, vertical };
|
||||
}
|
||||
</script>
|
||||
|
||||
<span
|
||||
bind:this={wrapperElement}
|
||||
class="tooltip-wrapper"
|
||||
on:mouseenter={handleMouseEnter}
|
||||
on:mouseleave={handleMouseLeave}
|
||||
@@ -61,7 +113,16 @@
|
||||
>
|
||||
<slot />
|
||||
{#if showTooltip}
|
||||
<span class="tooltip-text" role="status" aria-live="polite">
|
||||
<span
|
||||
bind:this={tooltipElement}
|
||||
class="tooltip-text"
|
||||
class:align-left={tooltipPosition.align === 'left'}
|
||||
class:align-right={tooltipPosition.align === 'right'}
|
||||
class:align-center={tooltipPosition.align === 'center'}
|
||||
class:position-bottom={tooltipPosition.vertical === 'bottom'}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
{/if}
|
||||
@@ -79,19 +140,43 @@
|
||||
.tooltip-text {
|
||||
position: absolute;
|
||||
bottom: 125%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background-color: var(--bg-color);
|
||||
color: var(--fg-color);
|
||||
border: 2px solid var(--border-color);
|
||||
padding: 0.5rem;
|
||||
z-index: 1000;
|
||||
width: max-content;
|
||||
max-width: 300px;
|
||||
max-width: min(300px, calc(100vw - 20px));
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
text-align: center;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
word-wrap: break-word;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* Horizontal alignment variants */
|
||||
.tooltip-text.align-center {
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.tooltip-text.align-left {
|
||||
left: 0;
|
||||
right: auto;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.tooltip-text.align-right {
|
||||
right: 0;
|
||||
left: auto;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Vertical position variant (when tooltip would overflow top) */
|
||||
.tooltip-text.position-bottom {
|
||||
bottom: auto;
|
||||
top: 125%;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
@@ -100,7 +185,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
.tooltip-text::after {
|
||||
/* Arrow positioning for center-aligned tooltip */
|
||||
.tooltip-text.align-center::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
@@ -111,9 +197,48 @@
|
||||
border-color: var(--border-color) transparent transparent transparent;
|
||||
}
|
||||
|
||||
.tooltip-text.align-center.position-bottom::after {
|
||||
top: auto;
|
||||
bottom: 100%;
|
||||
border-color: transparent transparent var(--border-color) transparent;
|
||||
}
|
||||
|
||||
/* Arrow positioning for left-aligned tooltip */
|
||||
.tooltip-text.align-left::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 1rem;
|
||||
border-width: 5px;
|
||||
border-style: solid;
|
||||
border-color: var(--border-color) transparent transparent transparent;
|
||||
}
|
||||
|
||||
.tooltip-text.align-left.position-bottom::after {
|
||||
top: auto;
|
||||
bottom: 100%;
|
||||
border-color: transparent transparent var(--border-color) transparent;
|
||||
}
|
||||
|
||||
/* Arrow positioning for right-aligned tooltip */
|
||||
.tooltip-text.align-right::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 1rem;
|
||||
border-width: 5px;
|
||||
border-style: solid;
|
||||
border-color: var(--border-color) transparent transparent transparent;
|
||||
}
|
||||
|
||||
.tooltip-text.align-right.position-bottom::after {
|
||||
top: auto;
|
||||
bottom: 100%;
|
||||
border-color: transparent transparent var(--border-color) transparent;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.tooltip-text {
|
||||
max-width: 250px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,15 +15,27 @@ export default defineConfig({
|
||||
svelte(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: ['favicon.ico'],
|
||||
includeAssets: ['favicon.ico', 'favicon.svg', 'icon-192.png', 'icon-512.png'],
|
||||
manifest: {
|
||||
name: 'Protocol Bicorder',
|
||||
short_name: 'Bicorder',
|
||||
description: 'A diagnostic tool for the study of protocols',
|
||||
theme_color: '#ffffff',
|
||||
background_color: '#ffffff',
|
||||
theme_color: '#1a1a2e',
|
||||
background_color: '#1a1a2e',
|
||||
display: 'standalone',
|
||||
icons: [
|
||||
{
|
||||
src: '/icon-192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png',
|
||||
purpose: 'any'
|
||||
},
|
||||
{
|
||||
src: '/icon-512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'any'
|
||||
},
|
||||
{
|
||||
src: '/icon.svg',
|
||||
sizes: 'any',
|
||||
|
||||