Files
protocol-bicorder/bicorder-app/src/components/AnalysisDisplay.svelte
2025-12-21 21:38:39 -07:00

388 lines
8.7 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import type { AnalysisGradient } from '../types';
import Tooltip from './Tooltip.svelte';
export let gradient: AnalysisGradient;
export let focusedMode: boolean = false;
const dispatch = createEventDispatcher<{
change: number | null;
notes: string;
}>();
let showNotes = false;
let notesText = gradient.notes || '';
function handleNotApplicable() {
dispatch('change', null);
}
function handleNotesSave() {
dispatch('notes', notesText);
showNotes = false;
}
function handleScaleClick(event: MouseEvent) {
if (gradient.automated) return;
const target = event.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
const x = event.clientX - rect.left;
const width = rect.width;
// Account for button padding to get actual text area
const styles = window.getComputedStyle(target);
const paddingLeft = parseFloat(styles.paddingLeft);
const paddingRight = parseFloat(styles.paddingRight);
const contentWidth = width - paddingLeft - paddingRight;
const xInContent = x - paddingLeft;
// Clamp to content area and get ratio
const ratio = Math.max(0, Math.min(1, xInContent / contentWidth));
// Map to 1-9 with proper centering on each character position
const value = Math.min(9, Math.max(1, Math.floor(ratio * 9) + 1));
dispatch('change', value);
}
function handleScaleTouch(event: TouchEvent) {
if (gradient.automated) return;
event.preventDefault();
const target = event.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
const touch = event.touches[0];
const x = touch.clientX - rect.left;
const width = rect.width;
// Account for button padding to get actual text area
const styles = window.getComputedStyle(target);
const paddingLeft = parseFloat(styles.paddingLeft);
const paddingRight = parseFloat(styles.paddingRight);
const contentWidth = width - paddingLeft - paddingRight;
const xInContent = x - paddingLeft;
// Clamp to content area and get ratio
const ratio = Math.max(0, Math.min(1, xInContent / contentWidth));
// Map to 1-9 with proper centering on each character position
const value = Math.min(9, Math.max(1, Math.floor(ratio * 9) + 1));
dispatch('change', value);
}
function renderBar(value: number | null): string {
// Fixed scale with 9 positions using ||||#||||
if (value === null) {
return '||||+||||';
}
// Value is 1-9, position the # marker at the right spot
const positions = [
'#||||||||',
'|#|||||||',
'||#||||||',
'|||#|||||',
'||||#||||',
'|||||#|||',
'||||||#||',
'|||||||#|',
'||||||||#'
];
return positions[value - 1];
}
$: barDisplay = renderBar(gradient.value);
</script>
<div class="analysis-gradient">
<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
class="bar"
class:interactive={!gradient.automated}
class:automated={gradient.automated}
on:click={handleScaleClick}
on:touchstart={handleScaleTouch}
role={gradient.automated ? 'img' : 'slider'}
aria-label={gradient.automated ? `Analysis showing value ${gradient.value || 'not calculated'}` : `Gradient scale between ${gradient.term_left} and ${gradient.term_right}`}
aria-valuemin={gradient.automated ? undefined : 1}
aria-valuemax={gradient.automated ? undefined : 9}
aria-valuenow={gradient.automated ? undefined : gradient.value}
title={gradient.automated ? 'Auto-calculated' : 'Click or tap on the scale to set value'}
disabled={gradient.automated}
>
{barDisplay}
</button>
</div>
{#if !focusedMode}
<div class="term right">
<Tooltip text={gradient.term_right_description}>
{gradient.term_right}
</Tooltip>
</div>
{/if}
</div>
<div class="value-display">
Value: {gradient.value !== null ? gradient.value : ''}
{#if gradient.automated}
<span class="auto-label">(auto-calculated)</span>
{/if}
</div>
<div class="controls">
{#if !gradient.automated}
<div class="button-row">
<button
class="na-btn"
class:active={gradient.value === null}
on:click={handleNotApplicable}
aria-label="Mark as not applicable"
title="Mark this gradient as not applicable"
>
N/A
</button>
<button
class="notes-btn"
class:has-notes={gradient.notes}
on:click={() => showNotes = !showNotes}
aria-label="Add notes"
>
{gradient.notes ? '📝' : ''} Notes
</button>
</div>
{:else}
<button
class="notes-btn"
class:has-notes={gradient.notes}
on:click={() => showNotes = !showNotes}
aria-label="Add notes"
>
{gradient.notes ? '📝' : ''} Notes
</button>
{/if}
</div>
{#if showNotes}
<div class="notes-editor">
<textarea
bind:value={notesText}
placeholder="Add notes or reference..."
rows="2"
/>
<div class="notes-buttons">
<button on:click={handleNotesSave}>Save</button>
<button on:click={() => showNotes = false}>Cancel</button>
</div>
</div>
{/if}
</div>
<style>
.analysis-gradient {
margin: 1rem 0;
padding: 0.5rem;
}
.gradient-row {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 1rem;
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;
}
.term.left {
text-align: right;
}
.term.right {
text-align: left;
}
.bar-container {
display: flex;
justify-content: center;
}
.bar {
font-family: 'Courier New', Courier, monospace;
font-size: 1.8rem;
white-space: nowrap;
letter-spacing: 0.2rem;
padding: 0.5rem 1rem;
user-select: none;
min-width: 240px;
text-align: center;
background-color: var(--bg-color);
color: var(--fg-color);
border: 2px solid var(--border-color);
}
.bar.automated {
cursor: default;
opacity: 0.6;
}
.bar.interactive {
cursor: pointer;
transition: border-color 0.2s;
}
.bar.interactive:hover {
border-color: var(--hover-color);
}
.bar.interactive:active {
background-color: var(--input-bg);
border-color: var(--fg-color);
}
.value-display {
text-align: center;
margin: 0.5rem 0;
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;
font-style: italic;
}
.controls {
display: flex;
justify-content: center;
margin-top: 0.5rem;
}
.button-row {
display: flex;
gap: 0.5rem;
width: auto;
}
.na-btn {
padding: 0.25rem 0.75rem;
font-size: 0.85rem;
opacity: 0.6;
flex-shrink: 0;
min-height: auto;
width: auto;
}
.na-btn:hover {
opacity: 1;
background-color: transparent;
color: var(--fg-color);
border-color: var(--hover-color);
}
.na-btn.active {
opacity: 1;
background-color: var(--input-bg);
border-color: var(--fg-color);
}
.notes-btn {
padding: 0.25rem 0.75rem;
font-size: 0.85rem;
opacity: 0.6;
min-height: auto;
width: auto;
}
.notes-btn:hover {
opacity: 1;
background-color: transparent;
color: var(--fg-color);
border-color: var(--hover-color);
}
.notes-btn.has-notes {
opacity: 1;
border-color: var(--hover-color);
}
.notes-btn.has-notes:hover {
background-color: transparent;
color: var(--fg-color);
}
.notes-editor {
margin-top: 0.5rem;
padding: 0.5rem;
border: 1px solid var(--border-color);
}
.notes-buttons {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.notes-buttons button {
flex: 1;
}
@media (max-width: 768px) {
.gradient-row {
grid-template-columns: 1fr;
grid-template-rows: auto auto auto;
gap: 0.5rem;
}
.term.left {
text-align: left;
grid-row: 1;
}
.bar-container {
grid-row: 2;
}
.term.right {
text-align: right;
grid-row: 3;
}
.bar {
font-size: 1.4rem;
min-width: 200px;
padding: 0.5rem;
}
}
</style>