Compare commits
6 Commits
d1f288cda8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b004e9fb19 | ||
|
|
900ec1a6af | ||
|
|
57b780fe95 | ||
| 6209990436 | |||
|
|
f53ada8196 | ||
|
|
56d1f7e11e |
25
README.md
25
README.md
@@ -67,13 +67,13 @@ An example output file from the template is maintained at `bicorder.txt`.
|
||||
Each gradient is represented this way:
|
||||
|
||||
```
|
||||
term_left < [|||||||||] > term_right
|
||||
term_left < [---------] > term_right
|
||||
```
|
||||
|
||||
To mark a gradient in a particular place, it is represented with a `#` like this:
|
||||
|
||||
```
|
||||
term_left < [||||#||||] > term_right
|
||||
term_left < [----#----] > term_right
|
||||
```
|
||||
|
||||
|
||||
@@ -89,27 +89,6 @@ The bicorder repository is equipped with a synthetic dataset of protocols, as we
|
||||
See [`analysis/`](analysis/) for complete documentation and materials.
|
||||
|
||||
|
||||
<!---
|
||||
### Gradient citations
|
||||
|
||||
- implicit / explicit
|
||||
- See Pomerantz book on Standards, p. 16: de facto and de jure
|
||||
- Social / technical
|
||||
- From Primavera's Protocol Art talk
|
||||
- Kafka / Whitehead (Asparouhva)
|
||||
- Measuring the extent to which the protocol imposes burdens on users as opposed to freeing them to focus on something else
|
||||
- flock / swarm (Fernández)
|
||||
- Measuring the degree of variation as opposed to uniformity the protocol enables
|
||||
- soft / hard (Stark)
|
||||
- dead / alive (Friend; Alston et al.; Walch)
|
||||
- related especially to whether it is actively performed)
|
||||
- insufficient / sufficient (Kittel & Shorin; Rao et al.)
|
||||
- Measuring the extent to which the protocol as such solves the problems it is designed to solve, or whether it relies on external mechanisms
|
||||
- tense / crystallized
|
||||
- Marc-Antoine Parent countered the idea of "engineered arguments" (which assume ongoing tension) with "crystallized arguments" (which memorialize past tensions that are no longer active). For instance, English is tense, Arabic numerals are crystallized.
|
||||
--->
|
||||
|
||||
|
||||
## Authorship and licensing
|
||||
|
||||
Initiated by [Nathan Schneider](https://nathanschneider.info) and available for use under the [Hippocratic License](https://firstdonoharm.dev/) (do no harm!). Several AI assistants, local and remote, were utilized in developing this tool.
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
import ExportControls from './components/ExportControls.svelte';
|
||||
import HelpModal from './components/HelpModal.svelte';
|
||||
import FormRecommendation from './components/FormRecommendation.svelte';
|
||||
import AnalysisTransitionBanner from './components/AnalysisTransitionBanner.svelte';
|
||||
import HamburgerMenu from './components/HamburgerMenu.svelte';
|
||||
import { BicorderClassifier } from './bicorder-classifier';
|
||||
|
||||
// Load bicorder data and model from build-time constants
|
||||
@@ -57,12 +59,12 @@
|
||||
});
|
||||
});
|
||||
|
||||
// Analysis screens (not in shortform)
|
||||
if (!isShortForm) {
|
||||
data.analysis.forEach((gradient, index) => {
|
||||
screens.push({ type: 'analysis', index, gradient });
|
||||
// Analysis screens (shown in both shortform and longform)
|
||||
// Show useful/not-useful gradient first (index 3), then the others
|
||||
const analysisOrder = [3, 0, 1, 2]; // useful/not-useful, hardness, polarization, bureaucratic
|
||||
analysisOrder.forEach((index) => {
|
||||
screens.push({ type: 'analysis', index, gradient: data.analysis[index] });
|
||||
});
|
||||
}
|
||||
|
||||
// Export screen
|
||||
screens.push({ type: 'export' });
|
||||
@@ -96,6 +98,32 @@
|
||||
viewMode = viewMode === 'focused' ? 'list' : 'focused';
|
||||
}
|
||||
|
||||
function toggleFormMode() {
|
||||
data = {
|
||||
...data,
|
||||
metadata: {
|
||||
...data.metadata,
|
||||
shortform: !data.metadata.shortform
|
||||
}
|
||||
};
|
||||
// Force refresh of components
|
||||
refreshKey++;
|
||||
// Reset to first screen when toggling
|
||||
currentScreen = 0;
|
||||
}
|
||||
|
||||
function showAnalysis() {
|
||||
// Find the first analysis screen
|
||||
const firstAnalysisIndex = screens.findIndex(s => s.type === 'analysis');
|
||||
if (firstAnalysisIndex !== -1) {
|
||||
currentScreen = firstAnalysisIndex;
|
||||
// Switch to focused mode if in list mode
|
||||
if (viewMode === 'list') {
|
||||
viewMode = 'focused';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate ASCII progress bar
|
||||
function generateProgressBar(current: number, total: number): string {
|
||||
const filled = '#';
|
||||
@@ -106,7 +134,80 @@
|
||||
return filled.repeat(filledCount) + empty.repeat(emptyCount);
|
||||
}
|
||||
|
||||
$: progressBar = generateProgressBar(currentScreen + 1, totalScreens);
|
||||
// Calculate total diagnostic screens (metadata + gradients only)
|
||||
$: diagnosticScreenCount = screens.filter(s => s.type === 'metadata' || s.type === 'gradient').length;
|
||||
|
||||
// Calculate current position within diagnostic screens
|
||||
$: diagnosticProgress = currentScreenData?.type === 'metadata' || currentScreenData?.type === 'gradient'
|
||||
? currentScreen + 1
|
||||
: diagnosticScreenCount; // Show as complete when in analysis or export
|
||||
|
||||
$: progressBar = generateProgressBar(diagnosticProgress, diagnosticScreenCount);
|
||||
|
||||
// Detect if we're on the first analysis screen
|
||||
$: isFirstAnalysisScreen = currentScreenData?.type === 'analysis' &&
|
||||
screens.findIndex(s => s.type === 'analysis') === currentScreen;
|
||||
|
||||
// Calculate completed gradients for the banner
|
||||
$: completedGradientsCount = data.diagnostic
|
||||
.flatMap(set => set.gradients)
|
||||
.filter(g => !data.metadata.shortform || g.shortform)
|
||||
.filter(g => g.value !== null).length;
|
||||
|
||||
$: totalGradientsCount = data.diagnostic
|
||||
.flatMap(set => set.gradients)
|
||||
.filter(g => !data.metadata.shortform || g.shortform).length;
|
||||
|
||||
// Calculate form recommendation (shared by FormRecommendation and AnalysisTransitionBanner)
|
||||
let formRecommendation: any = null;
|
||||
let hasEnoughDataForRecommendation = false;
|
||||
|
||||
$: {
|
||||
// Collect ratings from diagnostic data
|
||||
const ratings: Record<string, number> = {};
|
||||
let valueCount = 0;
|
||||
let shortFormTotal = 0;
|
||||
|
||||
data.diagnostic.forEach((diagnosticSet) => {
|
||||
const setName = diagnosticSet.set_name;
|
||||
diagnosticSet.gradients.forEach((gradient) => {
|
||||
// Count shortform gradients
|
||||
if (gradient.shortform) {
|
||||
shortFormTotal++;
|
||||
}
|
||||
|
||||
if (gradient.value !== null) {
|
||||
const dimensionName = `${setName}_${gradient.term_left}_vs_${gradient.term_right}`;
|
||||
ratings[dimensionName] = gradient.value;
|
||||
|
||||
// Only count shortform values for the threshold
|
||||
if (gradient.shortform) {
|
||||
valueCount++;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Only calculate if at least half of shortform gradients are complete
|
||||
const threshold = Math.ceil(shortFormTotal / 2);
|
||||
hasEnoughDataForRecommendation = valueCount >= threshold;
|
||||
|
||||
if (hasEnoughDataForRecommendation && data.metadata.shortform) {
|
||||
try {
|
||||
const prediction = classifier.predict(ratings, { detailed: true });
|
||||
const assessment = classifier.assessShortFormReadiness(ratings);
|
||||
formRecommendation = {
|
||||
...prediction,
|
||||
...assessment,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error calculating form recommendation:', error);
|
||||
formRecommendation = null;
|
||||
}
|
||||
} else {
|
||||
formRecommendation = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Load saved state from localStorage
|
||||
onMount(() => {
|
||||
@@ -338,7 +439,7 @@
|
||||
class="toolbar-btn help-btn"
|
||||
on:click={openHelp}
|
||||
aria-label="About the Bicorder"
|
||||
title="Help & Instructions"
|
||||
title="About"
|
||||
>
|
||||
?
|
||||
</button>
|
||||
@@ -350,20 +451,13 @@
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<FormRecommendation
|
||||
{classifier}
|
||||
diagnosticData={data.diagnostic}
|
||||
<HamburgerMenu
|
||||
{viewMode}
|
||||
isShortForm={data.metadata.shortform}
|
||||
on:switchToLongForm={handleSwitchToLongForm}
|
||||
on:toggleViewMode={toggleViewMode}
|
||||
on:toggleFormMode={toggleFormMode}
|
||||
on:showAnalysis={showAnalysis}
|
||||
/>
|
||||
<button
|
||||
class="toolbar-btn mode-toggle"
|
||||
on:click={toggleViewMode}
|
||||
aria-label="Toggle view mode"
|
||||
title={viewMode === 'focused' ? 'Switch to list view' : 'Switch to focused view'}
|
||||
>
|
||||
{viewMode === 'focused' ? '☰' : '⊡'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -480,6 +574,28 @@
|
||||
<div class="focused-screen gradient-screen">
|
||||
<div class="screen-category">ANALYSIS</div>
|
||||
|
||||
{#if isFirstAnalysisScreen}
|
||||
<AnalysisTransitionBanner
|
||||
recommendation={formRecommendation}
|
||||
isShortForm={data.metadata.shortform}
|
||||
completedGradients={completedGradientsCount}
|
||||
allAnalysisGradients={data.analysis}
|
||||
on:switchToLongForm={handleSwitchToLongForm}
|
||||
on:jumpToExport={() => {
|
||||
// Jump to the export screen (last screen)
|
||||
currentScreen = screens.length - 1;
|
||||
}}
|
||||
on:updateAnalysis={(e) => {
|
||||
data.analysis[e.detail.index].value = e.detail.value;
|
||||
data = data;
|
||||
}}
|
||||
on:updateAnalysisNotes={(e) => {
|
||||
data.analysis[e.detail.index].notes = e.detail.notes;
|
||||
data = data;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="gradient-focused">
|
||||
<div class="term-desc left-desc">
|
||||
<div class="term-name">← {screen.gradient.term_left}</div>
|
||||
@@ -554,14 +670,10 @@
|
||||
>
|
||||
{progressBar}
|
||||
</div>
|
||||
<div class="progress-numbers">{currentScreen + 1} / {totalScreens}</div>
|
||||
<div class="progress-numbers">{diagnosticProgress} / {diagnosticScreenCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<footer class="footer">
|
||||
<p>Initiated by <a href="https://nathanschneider.info" rel="nofollow">Nathan Schneider</a>; <a href="https://git.medlab.host/ntnsndr/protocol-bicorder/src/branch/main/bicorder-app" rel="nofollow">source code</a> licensed under the <a href="https://firstdonoharm.dev/" rel="nofollow">Hippocratic License</a> (do no harm!).</p>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
@@ -637,10 +749,6 @@
|
||||
background-color: var(--input-bg);
|
||||
}
|
||||
|
||||
.mode-toggle {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
@@ -655,19 +763,6 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: auto;
|
||||
padding: 2rem 1rem 1rem;
|
||||
border-top: 2px solid var(--border-color);
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.footer p {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.diagnostic-set, .analysis-section {
|
||||
margin: 2rem 0;
|
||||
padding: 1rem 0;
|
||||
@@ -697,6 +792,7 @@
|
||||
justify-content: center;
|
||||
min-height: 400px;
|
||||
padding: 2rem 0;
|
||||
padding-bottom: 180px; /* Space for fixed navigation */
|
||||
}
|
||||
|
||||
.focused-screen {
|
||||
@@ -763,16 +859,22 @@
|
||||
}
|
||||
|
||||
.focused-navigation {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--bg-color);
|
||||
border-top: 2px solid var(--border-color);
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
padding: 1.5rem 0;
|
||||
margin-top: auto;
|
||||
border-top: 2px solid var(--border-color);
|
||||
gap: 1rem;
|
||||
z-index: 100;
|
||||
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.focused-navigation.no-border {
|
||||
border-top: none;
|
||||
border-top: 2px solid var(--border-color);
|
||||
}
|
||||
|
||||
.nav-buttons {
|
||||
@@ -780,12 +882,15 @@
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
min-width: 120px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nav-btn:disabled {
|
||||
@@ -799,6 +904,9 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
@@ -861,32 +969,15 @@
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.mode-toggle {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
padding: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 1.5rem 0.5rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.05rem;
|
||||
}
|
||||
|
||||
.progress-numbers {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.focused-container {
|
||||
padding: 1rem 0;
|
||||
padding-bottom: 160px; /* Adjust for smaller mobile nav */
|
||||
}
|
||||
|
||||
.gradient-focused {
|
||||
@@ -894,10 +985,22 @@
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.focused-navigation {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
min-width: 80px;
|
||||
flex: 1;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.progress-numbers {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
359
bicorder-app/src/components/AnalysisTransitionBanner.svelte
Normal file
359
bicorder-app/src/components/AnalysisTransitionBanner.svelte
Normal file
@@ -0,0 +1,359 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import AnalysisDisplay from './AnalysisDisplay.svelte';
|
||||
import type { AnalysisGradient } from '../types';
|
||||
|
||||
export let recommendation: any = null;
|
||||
export let isShortForm: boolean;
|
||||
export let completedGradients: number;
|
||||
export let allAnalysisGradients: AnalysisGradient[];
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
switchToLongForm: void;
|
||||
jumpToExport: void;
|
||||
updateAnalysis: { index: number; value: number | null };
|
||||
updateAnalysisNotes: { index: number; notes: string };
|
||||
}>();
|
||||
|
||||
$: hasRecommendation = recommendation?.recommendedForm === 'long';
|
||||
|
||||
let showAllAnalysis = false;
|
||||
|
||||
function handleSwitchToLongForm() {
|
||||
dispatch('switchToLongForm');
|
||||
}
|
||||
|
||||
function handleJumpToExport() {
|
||||
dispatch('jumpToExport');
|
||||
}
|
||||
|
||||
function toggleAllAnalysis() {
|
||||
showAllAnalysis = !showAllAnalysis;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="transition-banner">
|
||||
<div class="banner-content">
|
||||
<div class="banner-header">
|
||||
<div class="completion-icon">✓</div>
|
||||
<div class="header-text">
|
||||
<h3>Diagnostics Complete</h3>
|
||||
<p class="subtext">Analysis calculated from {completedGradients} gradient{completedGradients !== 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recommendation Alert (if applicable) -->
|
||||
{#if isShortForm && hasRecommendation && recommendation}
|
||||
<div class="recommendation-alert">
|
||||
<div class="alert-header">
|
||||
<span class="alert-icon">⚠</span>
|
||||
<strong>Long Form Recommended</strong>
|
||||
</div>
|
||||
<div class="alert-body">
|
||||
<p class="alert-message">
|
||||
{#if recommendation.confidence < 60}
|
||||
• Low classification confidence ({recommendation.confidence}%)<br>
|
||||
{/if}
|
||||
{#if recommendation.completeness < 50}
|
||||
• Incomplete data ({recommendation.completeness}% of dimensions)<br>
|
||||
{/if}
|
||||
{#if recommendation.distanceToBoundary < 0.5}
|
||||
• Protocol near boundary between families<br>
|
||||
{/if}
|
||||
{#if recommendation.coverage < 75}
|
||||
• Missing key dimensions for reliable short-form classification ({recommendation.coverage}% coverage)<br>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="action-buttons">
|
||||
<button class="action-btn view-analysis-btn" on:click={toggleAllAnalysis}>
|
||||
{showAllAnalysis ? '▼' : '▶'} {showAllAnalysis ? 'Hide' : 'View'} Analysis
|
||||
</button>
|
||||
<button class="action-btn export-btn" on:click={handleJumpToExport}>
|
||||
Export Readings →
|
||||
</button>
|
||||
{#if isShortForm}
|
||||
<button class="action-btn longform-btn" on:click={handleSwitchToLongForm}>
|
||||
Switch to Long Form & Restart →
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- All Analysis Gradients (expandable) -->
|
||||
{#if showAllAnalysis}
|
||||
<div class="all-analysis-section">
|
||||
<div class="analysis-header">Analysis Gradients</div>
|
||||
{#each allAnalysisGradients as gradient, index}
|
||||
{#if index !== 3}
|
||||
<div class="analysis-item">
|
||||
<AnalysisDisplay
|
||||
{gradient}
|
||||
focusedMode={false}
|
||||
on:change={(e) => dispatch('updateAnalysis', { index, value: e.detail })}
|
||||
on:notes={(e) => dispatch('updateAnalysisNotes', { index, notes: e.detail })}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.transition-banner {
|
||||
margin-bottom: 2rem;
|
||||
animation: fadeInSlide 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeInSlide {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.banner-content {
|
||||
border: 2px solid var(--border-color);
|
||||
background: var(--input-bg);
|
||||
padding: 1.5rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.banner-content::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg,
|
||||
transparent 0%,
|
||||
var(--fg-color) 20%,
|
||||
var(--fg-color) 80%,
|
||||
transparent 100%);
|
||||
opacity: 0.3;
|
||||
animation: shimmer 2s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.banner-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.completion-icon {
|
||||
font-size: 2rem;
|
||||
color: #4ade80;
|
||||
animation: checkPop 0.5s ease-out 0.2s both;
|
||||
}
|
||||
|
||||
@keyframes checkPop {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.header-text h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: bold;
|
||||
letter-spacing: 0.05rem;
|
||||
}
|
||||
|
||||
.subtext {
|
||||
margin: 0.25rem 0 0 0;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.recommendation-alert {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: rgba(251, 191, 36, 0.1);
|
||||
border: 2px solid #fbbf24;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.alert-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.alert-icon {
|
||||
font-size: 1.2rem;
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.alert-header strong {
|
||||
font-size: 1rem;
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.alert-body {
|
||||
padding-left: 1.7rem;
|
||||
}
|
||||
|
||||
.alert-message {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: bold;
|
||||
border: 2px solid var(--border-color);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border-radius: 3px;
|
||||
background: var(--bg-color);
|
||||
color: var(--fg-color);
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: var(--fg-color);
|
||||
}
|
||||
|
||||
.export-btn {
|
||||
background: var(--fg-color);
|
||||
color: var(--bg-color);
|
||||
border-color: var(--fg-color);
|
||||
}
|
||||
|
||||
.export-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.longform-btn {
|
||||
background: #fbbf24;
|
||||
color: #1a1a2e;
|
||||
border-color: #fbbf24;
|
||||
}
|
||||
|
||||
.longform-btn:hover {
|
||||
background: #f59e0b;
|
||||
border-color: #f59e0b;
|
||||
box-shadow: 0 2px 8px rgba(251, 191, 36, 0.3);
|
||||
}
|
||||
|
||||
.view-analysis-btn {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.all-analysis-section {
|
||||
margin-top: 1.5rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-color);
|
||||
animation: slideDown 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
max-height: 2000px;
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.analysis-header {
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.analysis-item {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.analysis-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.banner-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.banner-header {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.completion-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.header-text h3 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.subtext {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.alert-body {
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
font-size: 0.9rem;
|
||||
padding: 0.6rem;
|
||||
}
|
||||
|
||||
.all-analysis-section {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,9 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import type { BicorderClassifier } from '../bicorder-classifier';
|
||||
|
||||
export let classifier: BicorderClassifier;
|
||||
export let diagnosticData: any;
|
||||
export let recommendation: any = null;
|
||||
export let hasEnoughData: boolean;
|
||||
export let isShortForm: boolean;
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
@@ -11,56 +10,6 @@
|
||||
}>();
|
||||
|
||||
let isExpanded = false;
|
||||
let recommendation: any = null;
|
||||
let hasEnoughData = false;
|
||||
|
||||
// Calculate recommendation based on current diagnostic data
|
||||
$: {
|
||||
// Collect ratings from diagnostic data
|
||||
const ratings: Record<string, number> = {};
|
||||
let valueCount = 0;
|
||||
let shortFormTotal = 0;
|
||||
|
||||
diagnosticData.forEach((diagnosticSet: any) => {
|
||||
const setName = diagnosticSet.set_name;
|
||||
diagnosticSet.gradients.forEach((gradient: any) => {
|
||||
// Count shortform gradients
|
||||
if (gradient.shortform) {
|
||||
shortFormTotal++;
|
||||
}
|
||||
|
||||
if (gradient.value !== null) {
|
||||
const dimensionName = `${setName}_${gradient.term_left}_vs_${gradient.term_right}`;
|
||||
ratings[dimensionName] = gradient.value;
|
||||
|
||||
// Only count shortform values for the threshold
|
||||
if (gradient.shortform) {
|
||||
valueCount++;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Only show if at least half of shortform gradients are complete
|
||||
const threshold = Math.ceil(shortFormTotal / 2);
|
||||
hasEnoughData = valueCount >= threshold;
|
||||
|
||||
if (hasEnoughData && isShortForm) {
|
||||
try {
|
||||
const prediction = classifier.predict(ratings, { detailed: true });
|
||||
const assessment = classifier.assessShortFormReadiness(ratings);
|
||||
recommendation = {
|
||||
...prediction,
|
||||
...assessment,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting form recommendation:', error);
|
||||
recommendation = null;
|
||||
}
|
||||
} else {
|
||||
recommendation = null;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleExpanded() {
|
||||
isExpanded = !isExpanded;
|
||||
@@ -147,9 +96,9 @@
|
||||
{/if}
|
||||
</p>
|
||||
<button class="switch-btn" on:click={handleSwitchToLongForm}>
|
||||
Switch to Long Form →
|
||||
Switch to Long Form & Restart →
|
||||
</button>
|
||||
<p class="note">All your current values will be preserved.</p>
|
||||
<p class="note">Returns to the beginning. All your current values will be preserved.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="recommendation-message good">
|
||||
|
||||
186
bicorder-app/src/components/HamburgerMenu.svelte
Normal file
186
bicorder-app/src/components/HamburgerMenu.svelte
Normal file
@@ -0,0 +1,186 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let viewMode: 'focused' | 'list' = 'focused';
|
||||
export let isShortForm: boolean = true;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let isOpen = false;
|
||||
|
||||
function toggleMenu() {
|
||||
isOpen = !isOpen;
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
isOpen = false;
|
||||
}
|
||||
|
||||
function handleToggleViewMode() {
|
||||
dispatch('toggleViewMode');
|
||||
closeMenu();
|
||||
}
|
||||
|
||||
function handleToggleFormMode() {
|
||||
dispatch('toggleFormMode');
|
||||
closeMenu();
|
||||
}
|
||||
|
||||
function handleShowAnalysis() {
|
||||
dispatch('showAnalysis');
|
||||
closeMenu();
|
||||
}
|
||||
|
||||
// Close menu when clicking outside
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
if (!target.closest('.hamburger-menu')) {
|
||||
closeMenu();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:click={handleClickOutside} />
|
||||
|
||||
<div class="hamburger-menu">
|
||||
<button
|
||||
class="toolbar-btn hamburger-btn"
|
||||
on:click|stopPropagation={toggleMenu}
|
||||
aria-label="Menu"
|
||||
title="Menu"
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
|
||||
{#if isOpen}
|
||||
<div class="menu-dropdown">
|
||||
<button class="menu-item" on:click={handleToggleViewMode}>
|
||||
<span class="menu-icon">{viewMode === 'focused' ? '☰' : '⊡'}</span>
|
||||
<span class="menu-text">
|
||||
{viewMode === 'focused' ? 'List mode' : 'Focused mode'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button class="menu-item" on:click={handleToggleFormMode}>
|
||||
<span class="menu-icon">{isShortForm ? '📋' : '📄'}</span>
|
||||
<span class="menu-text">
|
||||
{isShortForm ? 'Long form' : 'Short form'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button class="menu-item" on:click={handleShowAnalysis}>
|
||||
<span class="menu-icon">📊</span>
|
||||
<span class="menu-text">Show analysis</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.hamburger-menu {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
background: var(--bg-color);
|
||||
color: var(--fg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0.4;
|
||||
transition: opacity 0.2s, background-color 0.2s;
|
||||
min-height: auto;
|
||||
flex-shrink: 0;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.toolbar-btn:hover {
|
||||
opacity: 0.8;
|
||||
background-color: var(--input-bg);
|
||||
}
|
||||
|
||||
.hamburger-btn {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.menu-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.5rem);
|
||||
right: 0;
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
min-width: 200px;
|
||||
z-index: 1000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border: none;
|
||||
background: var(--bg-color);
|
||||
color: var(--fg-color);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: background-color 0.2s;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background-color: var(--input-bg);
|
||||
}
|
||||
|
||||
.menu-item:not(:last-child) {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
font-size: 1rem;
|
||||
min-width: 1.2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.menu-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.toolbar-btn {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.hamburger-btn {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.menu-dropdown {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
padding: 0.6rem 0.85rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
font-size: 0.9rem;
|
||||
min-width: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -18,19 +18,22 @@
|
||||
<div class="modal-backdrop" on:click={closeModal} on:keydown={() => {}} role="button" tabindex="-1">
|
||||
<div class="modal-content" on:click|stopPropagation on:keydown={() => {}} role="dialog" aria-modal="true">
|
||||
<div class="modal-header">
|
||||
<h2>Help & Instructions</h2>
|
||||
<button class="close-btn" on:click={closeModal} aria-label="Close help">×</button>
|
||||
<h2>About</h2>
|
||||
<button class="close-btn" on:click={closeModal} aria-label="Close">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<p>The Protocol Bicorder is a diagnostic tool for the study of protocols. It allows a human or machine user to evaluate protocol characteristics along a series of gradients between opposing terms.</p>
|
||||
<p>The name is a tribute to the tricorder, a fictional device in the Star Trek universe that the characters can use to obtain all manner of empirical data about their surroundings.</p>
|
||||
<p>To carry out the diagnostic, the analyst should consider the protocol from the perspective of one of the <code>gradients</code> at a time. The gradients invite the analyst to determine where the protocol lies between two terms.</p>
|
||||
<p>This is inevitably an interpretive exercise, but do your best to identify the most accurate <code>value</code>, with <code>1</code> being closest to <code>term_left</code> and <code>9</code> being closest to <code>term_right</code>.</p>
|
||||
<p>Choosing a <code>value</code> in the middle, such as <code>5</code>, can mean "a bit of both." Leaving the gradient <code>value</code> as <code>null</code> means "not applicable."</p>
|
||||
<p>There is a <code>notes</code> field for the analyst to add additional context or explanation.</p>
|
||||
<p>The <code>shortform</code> option allows the use of an abbreviated version of the bicorder, including only the most salient gradients.</p>
|
||||
<p>To carry out the diagnostic, consider the protocol from the perspective of one gradient at a time. Determine where the protocol lies between two terms, with <code>1</code> closest to <code>term_left</code> and <code>9</code> closest to <code>term_right</code>.</p>
|
||||
<p>A middle <code>value</code> like <code>5</code> means "a bit of both." Leaving the gradient <code>value</code> as <code>null</code> means "not applicable."</p>
|
||||
<p>Use the <code>notes</code> field to add context or explanation.</p>
|
||||
<p>Use the menu (☰) to toggle between focused/list modes and short/long forms. Short form includes only the most salient gradients.</p>
|
||||
<p>Happy protocol watching!</p>
|
||||
|
||||
<hr class="divider" />
|
||||
|
||||
<p class="credits">Initiated by <a href="https://nathanschneider.info" rel="nofollow">Nathan Schneider</a>; <a href="https://git.medlab.host/ntnsndr/protocol-bicorder/src/branch/main/bicorder-app" rel="nofollow">source code</a> licensed under the <a href="https://firstdonoharm.dev/" rel="nofollow">Hippocratic License</a> (do no harm!).</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -126,6 +129,20 @@
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.divider {
|
||||
margin: 2rem 0 1.5rem 0;
|
||||
border: none;
|
||||
border-top: 1px solid var(--border-color);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.credits {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.7;
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.modal-content {
|
||||
max-height: 90vh;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "Protocol Bicorder",
|
||||
"schema": "bicorder.schema.json",
|
||||
"version": "1.2.4",
|
||||
"version": "1.2.5",
|
||||
"description": "A diagnostic tool for the study of protocols",
|
||||
"author": "Nathan Schneider",
|
||||
"date_modified": "2025-12-02",
|
||||
@@ -22,18 +22,18 @@
|
||||
"gradients": [
|
||||
{
|
||||
"term_left": "explicit",
|
||||
"term_left_description": "The design is stated explicitly somewhere that is accessible to participants",
|
||||
"term_left_description": "Design is stated explicitly somewhere that is accessible to participants",
|
||||
"term_right": "implicit",
|
||||
"term_right_description": "The design is not stated explicitly and is learned by use",
|
||||
"term_right_description": "Design is not stated explicitly and is learned by use",
|
||||
"value": null,
|
||||
"notes": null,
|
||||
"shortform": false
|
||||
},
|
||||
{
|
||||
"term_left": "precise",
|
||||
"term_left_description": "The design is specified with a high level of precision that eliminates ambiguity in implementation",
|
||||
"term_left_description": "Specified with a high level of precision that eliminates ambiguity in implementation",
|
||||
"term_right": "interpretive",
|
||||
"term_right_description": "The design is ambiguous, allowing participants a wide range of interpretation",
|
||||
"term_right_description": "Ambiguous design, allowing participants a wide range of interpretation",
|
||||
"value": null,
|
||||
"notes": null,
|
||||
"shortform": true
|
||||
@@ -49,9 +49,9 @@
|
||||
},
|
||||
{
|
||||
"term_left": "documenting",
|
||||
"term_left_description": "The primary purpose is to document or validate activity that is occurring",
|
||||
"term_left_description": "Intended to document or validate activity that is occurring",
|
||||
"term_right": "enabling",
|
||||
"term_right_description": "The primary purpose is to enable activity that might not happen otherwise",
|
||||
"term_right_description": "Intended to enable activity that might not happen otherwise",
|
||||
"value": null,
|
||||
"notes": null,
|
||||
"shortform": false
|
||||
@@ -118,7 +118,7 @@
|
||||
},
|
||||
{
|
||||
"term_left": "self-enforcing",
|
||||
"term_left_description": "Rules are automatically enforced through its own mechanisms",
|
||||
"term_left_description": "Rules automatically enforced through its own mechanisms",
|
||||
"term_right": "enforced",
|
||||
"term_right_description": "Rules require external enforcement by authorities or institutions",
|
||||
"value": null,
|
||||
@@ -136,9 +136,9 @@
|
||||
},
|
||||
{
|
||||
"term_left": "obligatory",
|
||||
"term_left_description": "Participation is compulsory for a certain class of agents",
|
||||
"term_left_description": "Compulsory participation for a certain class of agents",
|
||||
"term_right": "voluntary",
|
||||
"term_right_description": "Participation in the protocol is optional and not coerced",
|
||||
"term_right_description": "Participation is optional and not coerced",
|
||||
"value": null,
|
||||
"notes": null,
|
||||
"shortform": true
|
||||
|
||||
Reference in New Issue
Block a user