Added classifer analysis to bicorder ascii and web app
This commit is contained in:
@@ -6,9 +6,15 @@
|
||||
import AnalysisDisplay from './components/AnalysisDisplay.svelte';
|
||||
import ExportControls from './components/ExportControls.svelte';
|
||||
import HelpModal from './components/HelpModal.svelte';
|
||||
import FormRecommendation from './components/FormRecommendation.svelte';
|
||||
import { BicorderClassifier } from './bicorder-classifier';
|
||||
|
||||
// Load bicorder data from build-time constant
|
||||
// Load bicorder data and model from build-time constants
|
||||
let data: BicorderState = JSON.parse(JSON.stringify(__BICORDER_DATA__));
|
||||
const model = __BICORDER_MODEL__;
|
||||
|
||||
// Initialize classifier
|
||||
const classifier = new BicorderClassifier(model, data.version);
|
||||
|
||||
// Initialize timestamp if not set
|
||||
if (!data.metadata.timestamp) {
|
||||
@@ -30,7 +36,7 @@
|
||||
| { type: 'export' };
|
||||
|
||||
// Calculate all screens based on current shortform setting
|
||||
function calculateScreens(): Screen[] {
|
||||
function calculateScreens(isShortForm: boolean): Screen[] {
|
||||
const screens: Screen[] = [];
|
||||
|
||||
// Metadata screen
|
||||
@@ -39,7 +45,7 @@
|
||||
// Diagnostic gradient screens
|
||||
data.diagnostic.forEach((diagnosticSet, setIndex) => {
|
||||
diagnosticSet.gradients.forEach((gradient, gradientIndex) => {
|
||||
if (!data.metadata.shortform || gradient.shortform) {
|
||||
if (!isShortForm || gradient.shortform) {
|
||||
screens.push({
|
||||
type: 'gradient',
|
||||
setIndex,
|
||||
@@ -52,7 +58,7 @@
|
||||
});
|
||||
|
||||
// Analysis screens (not in shortform)
|
||||
if (!data.metadata.shortform) {
|
||||
if (!isShortForm) {
|
||||
data.analysis.forEach((gradient, index) => {
|
||||
screens.push({ type: 'analysis', index, gradient });
|
||||
});
|
||||
@@ -64,10 +70,16 @@
|
||||
return screens;
|
||||
}
|
||||
|
||||
$: screens = calculateScreens();
|
||||
// Recalculate screens when data or shortform changes (explicit dependency)
|
||||
$: screens = calculateScreens(data.metadata.shortform);
|
||||
$: currentScreenData = screens[currentScreen];
|
||||
$: totalScreens = screens.length;
|
||||
|
||||
// Debug: log when screens change
|
||||
$: if (screens) {
|
||||
console.log(`Screens updated: ${screens.length} total, shortform: ${data.metadata.shortform}`);
|
||||
}
|
||||
|
||||
function goToNextScreen() {
|
||||
if (currentScreen < totalScreens - 1) {
|
||||
currentScreen++;
|
||||
@@ -188,7 +200,59 @@
|
||||
// Max deviation is 4 (from 1 or 9), min is 0 (at 5)
|
||||
// Higher deviation = more polarized = lower value
|
||||
const polarizationScore = 9 - (avgDeviation / 4) * 8;
|
||||
return Math.round(polarizationScore);
|
||||
|
||||
// Clamp to 1-9 range and round
|
||||
return Math.round(Math.max(1, Math.min(9, polarizationScore)));
|
||||
}
|
||||
|
||||
function ldaScoreToScale(ldaScore: number | null): number | null {
|
||||
/**
|
||||
* Convert LDA score to 1-9 scale.
|
||||
* LDA scores typically range from -4 to +4 (8 range)
|
||||
* Target scale is 1 to 9 (8 range)
|
||||
*
|
||||
* Formula: value = 5 + (ldaScore * 4/3)
|
||||
* - LDA -3 or less → 1 (bureaucratic)
|
||||
* - LDA 0 → 5 (boundary)
|
||||
* - LDA +3 or more → 9 (relational)
|
||||
*/
|
||||
if (ldaScore === null) return null;
|
||||
|
||||
// Scale: value = 5 + (ldaScore * 1.33)
|
||||
const value = 5 + (ldaScore * 4.0 / 3.0);
|
||||
|
||||
// Clamp to 1-9 range and round
|
||||
return Math.round(Math.max(1, Math.min(9, value)));
|
||||
}
|
||||
|
||||
function calculateBureaucratic(): number | null {
|
||||
// Collect all diagnostic gradients with their set and gradient info
|
||||
const ratings: Record<string, number> = {};
|
||||
|
||||
data.diagnostic.forEach((diagnosticSet) => {
|
||||
const setName = diagnosticSet.set_name;
|
||||
diagnosticSet.gradients.forEach((gradient) => {
|
||||
if (gradient.value !== null) {
|
||||
// Create dimension name in format: SetName_left_vs_right
|
||||
const dimensionName = `${setName}_${gradient.term_left}_vs_${gradient.term_right}`;
|
||||
ratings[dimensionName] = gradient.value;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Check if we have any ratings
|
||||
if (Object.keys(ratings).length === 0) return null;
|
||||
|
||||
try {
|
||||
// Get prediction from classifier (need detailed: true to get ldaScore)
|
||||
const result = classifier.predict(ratings, { detailed: true });
|
||||
|
||||
// Convert LDA score to 1-9 scale
|
||||
return ldaScoreToScale(result.ldaScore);
|
||||
} catch (error) {
|
||||
console.error('Error calculating bureaucratic/relational score:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Update automated analysis values reactively
|
||||
@@ -196,16 +260,29 @@
|
||||
data.analysis.forEach((item, index) => {
|
||||
if (item.automated) {
|
||||
if (index === 0) {
|
||||
// Hardness/Softness
|
||||
data.analysis[0].value = calculateHardness();
|
||||
} else if (index === 1) {
|
||||
// Polarized/Centrist
|
||||
data.analysis[1].value = calculatePolarization();
|
||||
} else if (index === 2) {
|
||||
// Bureaucratic/Relational (LDA classifier)
|
||||
data.analysis[2].value = calculateBureaucratic();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleMetadataUpdate(event: CustomEvent) {
|
||||
data.metadata = { ...data.metadata, ...event.detail };
|
||||
// Properly trigger reactivity for nested metadata changes
|
||||
data = {
|
||||
...data,
|
||||
metadata: { ...data.metadata, ...event.detail }
|
||||
};
|
||||
// Force refresh if shortform changed
|
||||
if (event.detail.shortform !== undefined) {
|
||||
refreshKey++;
|
||||
}
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
@@ -215,21 +292,79 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handleProgressBarClick(event: MouseEvent) {
|
||||
// Calculate which screen to jump to based on click position
|
||||
const target = event.currentTarget as HTMLElement;
|
||||
const rect = target.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const width = rect.width;
|
||||
|
||||
// Calculate ratio (0 to 1)
|
||||
const ratio = Math.max(0, Math.min(1, x / width));
|
||||
|
||||
// Calculate target screen (round to nearest)
|
||||
const targetScreen = Math.round(ratio * (totalScreens - 1));
|
||||
|
||||
// Jump to that screen
|
||||
currentScreen = targetScreen;
|
||||
}
|
||||
|
||||
function openHelp() {
|
||||
isHelpOpen = true;
|
||||
}
|
||||
|
||||
function handleSwitchToLongForm() {
|
||||
// Turn off shortform mode while preserving all entered values
|
||||
data = {
|
||||
...data,
|
||||
metadata: {
|
||||
...data.metadata,
|
||||
shortform: false
|
||||
}
|
||||
};
|
||||
// Force refresh of components
|
||||
refreshKey++;
|
||||
// Reset to first screen to show user the full form
|
||||
currentScreen = 0;
|
||||
}
|
||||
</script>
|
||||
|
||||
<HelpModal bind:isOpen={isHelpOpen} />
|
||||
|
||||
<main>
|
||||
<div class="header">
|
||||
<button class="help-btn" on:click={openHelp} aria-label="About the Bicorder">?</button>
|
||||
<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 class="header-left">
|
||||
<button
|
||||
class="toolbar-btn help-btn"
|
||||
on:click={openHelp}
|
||||
aria-label="About the Bicorder"
|
||||
title="Help & Instructions"
|
||||
>
|
||||
?
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="header-center">
|
||||
<div class="title">Protocol</div>
|
||||
<div class="title">BICORDER</div>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<FormRecommendation
|
||||
{classifier}
|
||||
diagnosticData={data.diagnostic}
|
||||
isShortForm={data.metadata.shortform}
|
||||
on:switchToLongForm={handleSwitchToLongForm}
|
||||
/>
|
||||
<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>
|
||||
|
||||
{#if viewMode === 'list'}
|
||||
@@ -407,7 +542,18 @@
|
||||
</div>
|
||||
|
||||
<div class="progress-indicator">
|
||||
<div class="progress-bar">{progressBar}</div>
|
||||
<div
|
||||
class="progress-bar clickable"
|
||||
on:click={handleProgressBarClick}
|
||||
role="slider"
|
||||
aria-label="Jump to screen"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={totalScreens - 1}
|
||||
aria-valuenow={currentScreen}
|
||||
title="Click to jump to a screen"
|
||||
>
|
||||
{progressBar}
|
||||
</div>
|
||||
<div class="progress-numbers">{currentScreen + 1} / {totalScreens}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -429,11 +575,31 @@
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
padding-bottom: 1rem;
|
||||
position: relative;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.header-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
@@ -442,51 +608,37 @@
|
||||
letter-spacing: 0.2rem;
|
||||
}
|
||||
|
||||
.help-btn {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
.toolbar-btn {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0;
|
||||
font-size: 1.2rem;
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
background: var(--bg-color);
|
||||
color: var(--fg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 50%;
|
||||
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;
|
||||
}
|
||||
|
||||
.help-btn:hover {
|
||||
.toolbar-btn.help-btn {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.toolbar-btn:hover {
|
||||
opacity: 0.8;
|
||||
background-color: var(--input-bg);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.description {
|
||||
@@ -657,6 +809,23 @@
|
||||
color: var(--fg-color);
|
||||
}
|
||||
|
||||
.progress-bar.clickable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: opacity 0.2s, transform 0.1s;
|
||||
padding: 0.5rem;
|
||||
margin: -0.5rem;
|
||||
}
|
||||
|
||||
.progress-bar.clickable:hover {
|
||||
opacity: 0.7;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.progress-bar.clickable:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.progress-numbers {
|
||||
font-size: 0.9rem;
|
||||
font-weight: bold;
|
||||
@@ -669,6 +838,11 @@
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
gap: 0.5rem;
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
@@ -677,15 +851,18 @@
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.help-btn {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
font-size: 1rem;
|
||||
.toolbar-btn {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.toolbar-btn.help-btn {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.mode-toggle {
|
||||
font-size: 0.85rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
|
||||
268
bicorder-app/src/bicorder-classifier.ts
Normal file
268
bicorder-app/src/bicorder-classifier.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* Bicorder Cluster Classifier
|
||||
*
|
||||
* Real-time protocol classification for the Bicorder web app.
|
||||
* Predicts which protocol family (Relational/Cultural vs Institutional/Bureaucratic)
|
||||
* a protocol belongs to based on dimension ratings.
|
||||
*
|
||||
* Usage:
|
||||
* import { BicorderClassifier } from './bicorder-classifier.js';
|
||||
*
|
||||
* const classifier = new BicorderClassifier(modelData);
|
||||
* const result = classifier.predict(ratings);
|
||||
* console.log(`Cluster: ${result.clusterName} (${result.confidence}% confidence)`);
|
||||
*/
|
||||
|
||||
export class BicorderClassifier {
|
||||
/**
|
||||
* @param {Object} model - Model data loaded from bicorder_model.json
|
||||
* @param {string} bicorderVersion - Version of bicorder.json being used
|
||||
*
|
||||
* Simple version-matching approach: The model includes a bicorder_version
|
||||
* field. When bicorder structure changes, update the version and retrain.
|
||||
*/
|
||||
constructor(model, bicorderVersion = null) {
|
||||
this.model = model;
|
||||
this.dimensions = model.dimensions;
|
||||
this.keyDimensions = model.key_dimensions;
|
||||
this.bicorderVersion = bicorderVersion;
|
||||
|
||||
// Check version compatibility
|
||||
if (bicorderVersion && model.bicorder_version && bicorderVersion !== model.bicorder_version) {
|
||||
console.warn(`Model version (${model.bicorder_version}) doesn't match bicorder version (${bicorderVersion}). Results may be inaccurate.`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Standardize values using the fitted scaler
|
||||
* @private
|
||||
*/
|
||||
_standardize(values) {
|
||||
return values.map((val, i) => {
|
||||
if (val === null || val === undefined) return null;
|
||||
return (val - this.model.scaler.mean[i]) / this.model.scaler.scale[i];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate LDA score (position on discriminant axis)
|
||||
* @private
|
||||
*/
|
||||
_ldaScore(scaledValues) {
|
||||
// Fill missing values with 0 (mean in scaled space)
|
||||
const filled = scaledValues.map(v => v === null ? 0 : v);
|
||||
|
||||
// Calculate: coef · x + intercept
|
||||
let score = this.model.lda.intercept;
|
||||
for (let i = 0; i < filled.length; i++) {
|
||||
score += this.model.lda.coefficients[i] * filled[i];
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Euclidean distance
|
||||
* @private
|
||||
*/
|
||||
_distance(a, b) {
|
||||
let sum = 0;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const diff = a[i] - b[i];
|
||||
sum += diff * diff;
|
||||
}
|
||||
return Math.sqrt(sum);
|
||||
}
|
||||
|
||||
/**
|
||||
* Predict cluster for given ratings
|
||||
*
|
||||
* @param {Object} ratings - Map of dimension names to values (1-9)
|
||||
* Can be partial - missing dimensions handled gracefully
|
||||
* @param {Object} options - Options
|
||||
* @param {boolean} options.detailed - Return detailed information (default: true)
|
||||
*
|
||||
* @returns {Object} Prediction result with:
|
||||
* - cluster: Cluster number (1 or 2)
|
||||
* - clusterName: Human-readable name
|
||||
* - confidence: Confidence percentage (0-100)
|
||||
* - completeness: Percentage of dimensions provided (0-100)
|
||||
* - recommendedForm: 'short' or 'long'
|
||||
* - ldaScore: Position on discriminant axis
|
||||
* - distanceToBoundary: Distance from cluster boundary
|
||||
*/
|
||||
predict(ratings, options = { detailed: true }) {
|
||||
// Convert ratings object to array
|
||||
const values = this.dimensions.map(dim => ratings[dim] ?? null);
|
||||
const providedCount = values.filter(v => v !== null).length;
|
||||
const completeness = providedCount / this.dimensions.length;
|
||||
|
||||
// Fill missing with neutral value (5 = middle of 1-9 scale)
|
||||
const filled = values.map(v => v ?? 5);
|
||||
|
||||
// Standardize
|
||||
const scaled = this._standardize(filled);
|
||||
|
||||
// Calculate LDA score
|
||||
const ldaScore = this._ldaScore(scaled);
|
||||
|
||||
// Predict cluster (LDA boundary at 0)
|
||||
// Positive score = cluster 2 (Institutional)
|
||||
// Negative score = cluster 1 (Relational)
|
||||
const cluster = ldaScore > 0 ? 2 : 1;
|
||||
const clusterName = this.model.cluster_names[cluster];
|
||||
|
||||
// Calculate confidence based on distance from boundary
|
||||
const distanceToBoundary = Math.abs(ldaScore);
|
||||
|
||||
// Confidence: higher when further from boundary
|
||||
// Normalize based on typical strong separation (3.0)
|
||||
let confidence = Math.min(1.0, distanceToBoundary / 3.0);
|
||||
|
||||
// Adjust for completeness
|
||||
const adjustedConfidence = confidence * (0.5 + 0.5 * completeness);
|
||||
|
||||
// Recommend form
|
||||
// Use long form when:
|
||||
// 1. Low confidence (< 0.6)
|
||||
// 2. Low completeness (< 50% of dimensions)
|
||||
// 3. Near boundary (< 0.5 distance)
|
||||
const shouldUseLongForm =
|
||||
adjustedConfidence < this.model.thresholds.confidence_low ||
|
||||
completeness < this.model.thresholds.completeness_low ||
|
||||
distanceToBoundary < this.model.thresholds.boundary_distance_low;
|
||||
|
||||
const recommendedForm = shouldUseLongForm ? 'long' : 'short';
|
||||
|
||||
const basicResult = {
|
||||
cluster,
|
||||
clusterName,
|
||||
confidence: Math.round(adjustedConfidence * 100),
|
||||
completeness: Math.round(completeness * 100),
|
||||
recommendedForm,
|
||||
};
|
||||
|
||||
if (!options.detailed) {
|
||||
return basicResult;
|
||||
}
|
||||
|
||||
// Calculate distances to cluster centroids
|
||||
const filledScaled = scaled.map(v => v ?? 0);
|
||||
const distances = {};
|
||||
for (const [clusterId, centroid] of Object.entries(this.model.cluster_centroids_scaled)) {
|
||||
distances[clusterId] = this._distance(filledScaled, centroid);
|
||||
}
|
||||
|
||||
// Count key dimensions provided
|
||||
const keyDimensionsProvided = this.keyDimensions.filter(
|
||||
dim => ratings[dim] !== null && ratings[dim] !== undefined
|
||||
).length;
|
||||
|
||||
return {
|
||||
...basicResult,
|
||||
ldaScore,
|
||||
distanceToBoundary,
|
||||
dimensionsProvided: providedCount,
|
||||
dimensionsTotal: this.dimensions.length,
|
||||
keyDimensionsProvided,
|
||||
keyDimensionsTotal: this.keyDimensions.length,
|
||||
distancesToCentroids: distances,
|
||||
rawConfidence: Math.round(confidence * 100),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get explanation of classification
|
||||
*
|
||||
* @param {Object} ratings - Dimension ratings
|
||||
* @returns {string} Human-readable explanation
|
||||
*/
|
||||
explainClassification(ratings) {
|
||||
const result = this.predict(ratings, { detailed: true });
|
||||
const lines = [];
|
||||
|
||||
lines.push(`Protocol Classification: ${result.clusterName}`);
|
||||
lines.push(`Confidence: ${result.confidence}%`);
|
||||
lines.push('');
|
||||
|
||||
if (result.cluster === 2) {
|
||||
lines.push('This protocol leans toward Institutional/Bureaucratic characteristics:');
|
||||
lines.push(' • More likely to be formal, standardized, top-down');
|
||||
lines.push(' • May involve state/corporate enforcement');
|
||||
lines.push(' • Tends toward precise, documented procedures');
|
||||
} else {
|
||||
lines.push('This protocol leans toward Relational/Cultural characteristics:');
|
||||
lines.push(' • More likely to be emergent, community-based');
|
||||
lines.push(' • May involve voluntary participation');
|
||||
lines.push(' • Tends toward interpretive, flexible practices');
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push(`Distance from boundary: ${result.distanceToBoundary.toFixed(2)}`);
|
||||
|
||||
if (result.distanceToBoundary < 0.5) {
|
||||
lines.push('⚠️ This protocol is near the boundary between families.');
|
||||
lines.push(' It may exhibit characteristics of both types.');
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push(`Completeness: ${result.completeness}% (${result.dimensionsProvided}/${result.dimensionsTotal} dimensions)`);
|
||||
|
||||
if (result.completeness < 100) {
|
||||
lines.push('Note: Missing dimensions filled with neutral values (5)');
|
||||
lines.push(' Confidence improves with complete data');
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push(`Recommended form: ${result.recommendedForm.toUpperCase()}`);
|
||||
|
||||
if (result.recommendedForm === 'long') {
|
||||
lines.push('Reason: Use long form for:');
|
||||
if (result.confidence < 60) {
|
||||
lines.push(' • Low classification confidence');
|
||||
}
|
||||
if (result.completeness < 50) {
|
||||
lines.push(' • Incomplete data');
|
||||
}
|
||||
if (result.distanceToBoundary < 0.5) {
|
||||
lines.push(' • Ambiguous positioning between families');
|
||||
}
|
||||
} else {
|
||||
lines.push(`Reason: High confidence classification with ${result.completeness}% data`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of key dimensions for short form
|
||||
* @returns {Array<string>} Dimension names
|
||||
*/
|
||||
getKeyDimensions() {
|
||||
return [...this.keyDimensions];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if enough key dimensions are provided for reliable short-form classification
|
||||
* @param {Object} ratings - Current ratings
|
||||
* @returns {Object} Assessment with recommendation
|
||||
*/
|
||||
assessShortFormReadiness(ratings) {
|
||||
const keyProvided = this.keyDimensions.filter(
|
||||
dim => ratings[dim] !== null && ratings[dim] !== undefined
|
||||
);
|
||||
|
||||
const coverage = keyProvided.length / this.keyDimensions.length;
|
||||
const isReady = coverage >= 0.75; // 75% of key dimensions
|
||||
|
||||
return {
|
||||
ready: isReady,
|
||||
keyDimensionsProvided: keyProvided.length,
|
||||
keyDimensionsTotal: this.keyDimensions.length,
|
||||
coverage: Math.round(coverage * 100),
|
||||
missingKeyDimensions: this.keyDimensions.filter(
|
||||
dim => !ratings[dim]
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
function renderBar(value: number | null): string {
|
||||
// Fixed scale with 9 positions using ||||#||||
|
||||
if (value === null) {
|
||||
return '||||·||||';
|
||||
return '||||+||||';
|
||||
}
|
||||
// Value is 1-9, position the # marker at the right spot
|
||||
const positions = [
|
||||
|
||||
450
bicorder-app/src/components/FormRecommendation.svelte
Normal file
450
bicorder-app/src/components/FormRecommendation.svelte
Normal file
@@ -0,0 +1,450 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import type { BicorderClassifier } from '../bicorder-classifier';
|
||||
|
||||
export let classifier: BicorderClassifier;
|
||||
export let diagnosticData: any;
|
||||
export let isShortForm: boolean;
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
switchToLongForm: void;
|
||||
}>();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function handleSwitchToLongForm() {
|
||||
dispatch('switchToLongForm');
|
||||
isExpanded = false;
|
||||
}
|
||||
|
||||
// Determine status: 'good' (green) or 'warning' (yellow/orange)
|
||||
$: status = recommendation?.recommendedForm === 'long' ? 'warning' : 'good';
|
||||
$: showIndicator = hasEnoughData && isShortForm && recommendation;
|
||||
</script>
|
||||
|
||||
{#if showIndicator}
|
||||
<div class="form-recommendation" class:expanded={isExpanded}>
|
||||
<button
|
||||
class="indicator"
|
||||
class:good={status === 'good'}
|
||||
class:warning={status === 'warning'}
|
||||
on:click={toggleExpanded}
|
||||
aria-label="Data quality indicator"
|
||||
title={status === 'good' ? 'Short form working well' : 'Long form recommended'}
|
||||
>
|
||||
<span class="light"></span>
|
||||
</button>
|
||||
|
||||
{#if isExpanded}
|
||||
<div class="panel-backdrop" on:click={toggleExpanded} on:keydown={() => {}} role="button" tabindex="-1">
|
||||
<div class="details-panel" on:click|stopPropagation on:keydown={() => {}} role="dialog" aria-modal="true">
|
||||
<div class="panel-header">
|
||||
<h3>Data Quality Assessment</h3>
|
||||
<button class="close-btn" on:click={toggleExpanded} aria-label="Close">+</button>
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
<div class="metric">
|
||||
<span class="metric-label">Classification Confidence:</span>
|
||||
<span class="metric-value" class:low={recommendation.confidence < 60}>
|
||||
{recommendation.confidence}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="metric">
|
||||
<span class="metric-label">Data Completeness:</span>
|
||||
<span class="metric-value" class:low={recommendation.completeness < 50}>
|
||||
{recommendation.completeness}% ({recommendation.dimensionsProvided}/{recommendation.dimensionsTotal} dimensions)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="metric">
|
||||
<span class="metric-label">Key Dimensions:</span>
|
||||
<span class="metric-value" class:low={recommendation.coverage < 75}>
|
||||
{recommendation.coverage}% ({recommendation.keyDimensionsProvided}/{recommendation.keyDimensionsTotal})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="classification">
|
||||
<div class="classification-label">Current Classification:</div>
|
||||
<div class="classification-value">
|
||||
<strong>{recommendation.clusterName}</strong>
|
||||
{#if recommendation.distanceToBoundary < 0.5}
|
||||
<span class="boundary-warning">(Near boundary)</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if recommendation.recommendedForm === 'long'}
|
||||
<div class="recommendation-message warning">
|
||||
<strong>⚠ Long Form Recommended</strong>
|
||||
<p>
|
||||
{#if recommendation.confidence < 60}
|
||||
• Low classification confidence<br>
|
||||
{/if}
|
||||
{#if recommendation.completeness < 50}
|
||||
• Incomplete data (less than 50% 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<br>
|
||||
{/if}
|
||||
</p>
|
||||
<button class="switch-btn" on:click={handleSwitchToLongForm}>
|
||||
Switch to Long Form →
|
||||
</button>
|
||||
<p class="note">All your current values will be preserved.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="recommendation-message good">
|
||||
<strong>✓ Short Form Working Well</strong>
|
||||
<p>
|
||||
Your current data provides {recommendation.confidence}% confidence classification.
|
||||
Continue with short form or switch to long form for more detailed analysis.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.form-recommendation {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.indicator {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-color);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease;
|
||||
padding: 0;
|
||||
opacity: 0.4;
|
||||
min-height: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.indicator:hover {
|
||||
opacity: 0.8;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.light {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 50%;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.indicator.good .light {
|
||||
background: #4ade80;
|
||||
box-shadow: 0 0 8px rgba(74, 222, 128, 0.5);
|
||||
}
|
||||
|
||||
.indicator.warning .light {
|
||||
background: #fbbf24;
|
||||
box-shadow: 0 0 8px rgba(251, 191, 36, 0.5);
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-backdrop {
|
||||
/* Hidden on desktop - only visible on mobile */
|
||||
display: none;
|
||||
}
|
||||
|
||||
.details-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.5rem);
|
||||
right: 0;
|
||||
width: 400px;
|
||||
max-width: calc(100vw - 2rem);
|
||||
background: var(--bg-color);
|
||||
border: 2px solid var(--border-color);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
animation: slideIn 0.2s ease-out;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.panel-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--fg-color);
|
||||
opacity: 0.6;
|
||||
padding: 0;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
opacity: 1;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
padding: 1rem;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.metric {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-weight: bold;
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.metric-value.low {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.classification {
|
||||
margin: 1rem 0;
|
||||
padding: 0.75rem;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.classification-label {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.8;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.classification-value {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.classification-value strong {
|
||||
color: var(--fg-color);
|
||||
}
|
||||
|
||||
.boundary-warning {
|
||||
color: #fbbf24;
|
||||
font-size: 0.85rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.recommendation-message {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
border: 2px solid;
|
||||
}
|
||||
|
||||
.recommendation-message.good {
|
||||
background: rgba(74, 222, 128, 0.1);
|
||||
border-color: #4ade80;
|
||||
}
|
||||
|
||||
.recommendation-message.warning {
|
||||
background: rgba(251, 191, 36, 0.1);
|
||||
border-color: #fbbf24;
|
||||
}
|
||||
|
||||
.recommendation-message strong {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.recommendation-message p {
|
||||
margin: 0.5rem 0;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.switch-btn {
|
||||
width: 100%;
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem;
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
background: #fbbf24;
|
||||
color: #1a1a2e;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.switch-btn:hover {
|
||||
background: #f59e0b;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(251, 191, 36, 0.3);
|
||||
}
|
||||
|
||||
.note {
|
||||
font-size: 0.75rem;
|
||||
font-style: italic;
|
||||
opacity: 0.7;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.indicator {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.light {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
}
|
||||
|
||||
/* Modal-like on mobile */
|
||||
.panel-backdrop {
|
||||
display: flex;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 2000;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.details-panel {
|
||||
position: relative;
|
||||
top: auto;
|
||||
right: auto;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
max-height: 85vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.panel-header h3 {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.metric {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -74,7 +74,7 @@
|
||||
function renderBar(value: number | null): string {
|
||||
// Slider-style visualization with brackets and value number
|
||||
if (value === null) {
|
||||
return '[----X----]';
|
||||
return '[----+----]';
|
||||
}
|
||||
// Value is 1-9, show the number at its position along the slider
|
||||
const bars = [
|
||||
|
||||
1
bicorder-app/src/vite-env.d.ts
vendored
1
bicorder-app/src/vite-env.d.ts
vendored
@@ -2,6 +2,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare const __BICORDER_DATA__: any
|
||||
declare const __BICORDER_MODEL__: any
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_APP_TITLE: string
|
||||
|
||||
@@ -9,6 +9,11 @@ const bicorderData = JSON.parse(
|
||||
fs.readFileSync(path.resolve(__dirname, '../bicorder.json'), 'utf-8')
|
||||
)
|
||||
|
||||
// Read bicorder_model.json at build time
|
||||
const bicorderModel = JSON.parse(
|
||||
fs.readFileSync(path.resolve(__dirname, '../analysis/bicorder_model.json'), 'utf-8')
|
||||
)
|
||||
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
plugins: [
|
||||
@@ -62,6 +67,7 @@ export default defineConfig({
|
||||
})
|
||||
],
|
||||
define: {
|
||||
'__BICORDER_DATA__': JSON.stringify(bicorderData)
|
||||
'__BICORDER_DATA__': JSON.stringify(bicorderData),
|
||||
'__BICORDER_MODEL__': JSON.stringify(bicorderModel)
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user