Initial commit - build-valuecurve source code

This commit is contained in:
TBQ Guy 2025-12-27 21:43:50 +05:30
commit 9631e92147
47 changed files with 38268 additions and 0 deletions

28
.gitignore vendored Normal file
View file

@ -0,0 +1,28 @@
# Dependencies
node_modules/
# Build outputs
.svelte-kit/
build/
dist/
_site/
# Environment
.env
.env.local
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Quarto
.quarto/
# Logs
*.log

17
Caddyfile.local Normal file
View file

@ -0,0 +1,17 @@
# Local Caddyfile for build.valuecurve.co testing
# Run with: caddy run --config Caddyfile.local
localhost:8080 {
root * "/Users/sarfaraz.mulla/01-context-lab/SkillBox-Learning/deeplearning.ai tutorials/build-valuecurve/dist"
# Proxy API calls to backend (mirrors production)
handle /api/* {
reverse_proxy localhost:8000
}
# Serve static files with SPA fallback
handle {
try_files {path} {path}/ {path}/index.html /index.html
file_server
}
}

15
_quarto.yml Normal file
View file

@ -0,0 +1,15 @@
project:
type: website
output-dir: _site
website:
title: "Build ValueCurve"
format:
html:
theme: cosmo
toc: true
toc-location: right
toc-depth: 3
embed-resources: true
mainfont: "Helvetica Neue"

12
apps/flowchart/index.html Normal file
View file

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Statistical Test Decision Flowchart - Svelte</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1454
apps/flowchart/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,20 @@
{
"name": "statistical-test-flowchart-svelte",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "Statistical Test Decision Flowchart - Svelte Version",
"dependencies": {
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@xyflow/svelte": "^1.5.0",
"svelte": "^5.46.0",
"vite": "^7.3.0"
}
}

View file

@ -0,0 +1,415 @@
<script>
import {
SvelteFlow,
Controls,
Background,
MiniMap,
} from '@xyflow/svelte';
import '@xyflow/svelte/dist/style.css';
// Node styles
const startNodeStyle = 'background: #c8e6c9; border: 2px solid #2e7d32; border-radius: 25px; padding: 15px 25px; font-weight: bold; font-size: 14px;';
const decisionNodeStyle = 'background: #fff3e0; border: 2px solid #e65100; border-radius: 8px; padding: 12px 18px; font-weight: 500; font-size: 12px; text-align: center;';
const testNodeStyle = 'background: #e1f5fe; border: 2px solid #01579b; border-radius: 8px; padding: 12px 20px; font-weight: bold; font-size: 12px; color: #01579b; cursor: pointer;';
let nodes = $state([
// Start Node - centered at top
{ id: 'start', position: { x: 500, y: 0 }, data: { label: 'What is your research goal?' }, style: startNodeStyle },
// Level 1 - main question
{ id: 'q1', position: { x: 450, y: 100 }, data: { label: 'Comparing groups or measuring relationship?' }, style: decisionNodeStyle + ' width: 180px;' },
// Level 2 - split into comparing groups vs relationship
{ id: 'q2', position: { x: 280, y: 230 }, data: { label: 'How many groups?' }, style: decisionNodeStyle },
{ id: 'q3', position: { x: 820, y: 230 }, data: { label: 'Data type?' }, style: decisionNodeStyle },
// Level 3 - group comparisons and relationship branches
{ id: 'q4', position: { x: 80, y: 360 }, data: { label: 'Data normal?' }, style: decisionNodeStyle },
{ id: 'q5', position: { x: 280, y: 360 }, data: { label: 'Independent or paired?' }, style: decisionNodeStyle + ' width: 130px;' },
{ id: 'q6', position: { x: 520, y: 360 }, data: { label: 'Data normal?' }, style: decisionNodeStyle },
{ id: 'q11', position: { x: 720, y: 360 }, data: { label: 'Data normal?' }, style: decisionNodeStyle },
{ id: 'q12', position: { x: 920, y: 360 }, data: { label: 'Sample size adequate?' }, style: decisionNodeStyle + ' width: 120px;' },
// Level 4 - Test results and decisions (left branch: 1 group vs value)
{ id: 't1', position: { x: 20, y: 480 }, data: { label: 'One-Sample T-Test' }, style: testNodeStyle },
{ id: 't2', position: { x: 20, y: 560 }, data: { label: 'Wilcoxon Signed-Rank' }, style: testNodeStyle },
// Level 4 - Independent/Paired branch
{ id: 'q7', position: { x: 200, y: 480 }, data: { label: 'Data normal?' }, style: decisionNodeStyle },
{ id: 'q8', position: { x: 360, y: 480 }, data: { label: 'Data normal?' }, style: decisionNodeStyle },
// Level 4 - 3+ groups branch (Data normal? q6 -> Equal variances?)
{ id: 'q10', position: { x: 560, y: 480 }, data: { label: 'Equal variances?' }, style: decisionNodeStyle },
{ id: 't8', position: { x: 700, y: 480 }, data: { label: 'Kruskal-Wallis Test' }, style: testNodeStyle },
// Level 4 - Correlation branch (below q11 "Data normal?")
{ id: 't11', position: { x: 740, y: 620 }, data: { label: 'Pearson Correlation' }, style: testNodeStyle },
{ id: 't12', position: { x: 890, y: 620 }, data: { label: 'Spearman Correlation' }, style: testNodeStyle },
// Level 4 - Categorical branch (far right)
{ id: 't13', position: { x: 940, y: 480 }, data: { label: 'Chi-Square Test' }, style: testNodeStyle },
{ id: 't14', position: { x: 1080, y: 480 }, data: { label: "Fisher's Exact Test" }, style: testNodeStyle },
// Level 5 - Below q7 (Independent -> Data normal?)
{ id: 'q9', position: { x: 150, y: 600 }, data: { label: 'Equal variances?' }, style: decisionNodeStyle },
{ id: 't3', position: { x: 280, y: 600 }, data: { label: 'Mann-Whitney U' }, style: testNodeStyle },
// Level 5 - Below q8 (Paired -> Data normal?)
{ id: 't6', position: { x: 340, y: 600 }, data: { label: 'Paired T-Test' }, style: testNodeStyle },
{ id: 't7', position: { x: 340, y: 680 }, data: { label: 'Wilcoxon Signed-Rank' }, style: testNodeStyle },
// Level 5 - ANOVA results (below q10 Equal variances)
{ id: 't9', position: { x: 540, y: 580 }, data: { label: 'One-Way ANOVA' }, style: testNodeStyle },
{ id: 't10', position: { x: 680, y: 580 }, data: { label: "Welch's ANOVA" }, style: testNodeStyle },
// Level 6 - Final T-tests (below q9 Equal variances)
{ id: 't4', position: { x: 100, y: 720 }, data: { label: 'Independent T-Test' }, style: testNodeStyle },
{ id: 't5', position: { x: 240, y: 720 }, data: { label: "Welch's T-Test" }, style: testNodeStyle },
]);
let edges = $state([
{ id: 'e-start-q1', source: 'start', target: 'q1' },
{ id: 'e-q1-q2', source: 'q1', target: 'q2', label: 'Comparing Groups' },
{ id: 'e-q1-q3', source: 'q1', target: 'q3', label: 'Measuring Relationship' },
{ id: 'e-q2-q4', source: 'q2', target: 'q4', label: '1 group vs value' },
{ id: 'e-q2-q5', source: 'q2', target: 'q5', label: '2 groups' },
{ id: 'e-q2-q6', source: 'q2', target: 'q6', label: '3+ groups' },
{ id: 'e-q3-q11', source: 'q3', target: 'q11', label: 'Both Continuous' },
{ id: 'e-q3-q12', source: 'q3', target: 'q12', label: 'Both Categorical' },
{ id: 'e-q4-t1', source: 'q4', target: 't1', label: 'Yes' },
{ id: 'e-q4-t2', source: 'q4', target: 't2', label: 'No' },
{ id: 'e-q5-q7', source: 'q5', target: 'q7', label: 'Independent' },
{ id: 'e-q5-q8', source: 'q5', target: 'q8', label: 'Paired' },
{ id: 'e-q6-q10', source: 'q6', target: 'q10', label: 'Yes' },
{ id: 'e-q6-t8', source: 'q6', target: 't8', label: 'No' },
{ id: 'e-q7-q9', source: 'q7', target: 'q9', label: 'Yes' },
{ id: 'e-q7-t3', source: 'q7', target: 't3', label: 'No' },
{ id: 'e-q8-t6', source: 'q8', target: 't6', label: 'Yes' },
{ id: 'e-q8-t7', source: 'q8', target: 't7', label: 'No' },
{ id: 'e-q9-t4', source: 'q9', target: 't4', label: 'Yes' },
{ id: 'e-q9-t5', source: 'q9', target: 't5', label: 'No' },
{ id: 'e-q10-t9', source: 'q10', target: 't9', label: 'Yes' },
{ id: 'e-q10-t10', source: 'q10', target: 't10', label: 'No' },
{ id: 'e-q11-t11', source: 'q11', target: 't11', label: 'Yes' },
{ id: 'e-q11-t12', source: 'q11', target: 't12', label: 'No' },
{ id: 'e-q12-t13', source: 'q12', target: 't13', label: 'Yes (freq ≥ 5)' },
{ id: 'e-q12-t14', source: 'q12', target: 't14', label: 'No (small)' },
]);
// Test descriptions
const testDescriptions = {
't1': { name: 'One-Sample T-Test', description: 'Compares a sample mean to a known or hypothesized population value.', use: 'Testing if your sample differs from a target value.', example: 'Is the average weight of products equal to 500g specification?', parametric: true },
't2': { name: 'Wilcoxon Signed-Rank Test', description: 'Non-parametric alternative to one-sample t-test for non-normal data.', use: 'When data is skewed or ordinal.', example: 'Do median satisfaction ratings differ from neutral (3)?', parametric: false },
't3': { name: 'Mann-Whitney U Test', description: 'Compares distributions of two independent groups without assuming normality.', use: 'Non-parametric alternative to independent t-test.', example: 'Do two groups have different rank distributions?', parametric: false },
't4': { name: 'Independent T-Test', description: 'Compares means of two independent groups with equal variances.', use: 'Classic comparison of two unrelated groups.', example: 'Do men and women differ in average height?', parametric: true },
't5': { name: "Welch's T-Test", description: 'Compares means of two groups without assuming equal variances.', use: 'Robust alternative when variances differ.', example: 'Comparing treatment vs control with different variability.', parametric: true },
't6': { name: 'Paired T-Test', description: 'Compares means of two related measurements (same subjects measured twice).', use: 'Before-after studies, matched pairs.', example: 'Does blood pressure change after medication?', parametric: true },
't7': { name: 'Wilcoxon Signed-Rank Test', description: 'Non-parametric paired comparison for non-normal data.', use: 'Paired data that violates normality.', example: 'Do rankings improve after training?', parametric: false },
't8': { name: 'Kruskal-Wallis Test', description: 'Non-parametric comparison of 3+ groups based on ranks.', use: 'Alternative to ANOVA for non-normal data.', example: 'Do satisfaction scores differ across 4 product types?', parametric: false },
't9': { name: 'One-Way ANOVA', description: 'Compares means across 3 or more groups simultaneously.', use: 'Testing if any group differs from others.', example: 'Do students from different schools perform differently?', parametric: true },
't10': { name: "Welch's ANOVA", description: 'ANOVA alternative when group variances are unequal.', use: 'Robust multi-group comparison.', example: 'Comparing yields across treatments with different variability.', parametric: true },
't11': { name: 'Pearson Correlation', description: 'Measures linear relationship strength between two continuous variables.', use: 'Quantifying linear association.', example: 'How strongly are height and weight related?', parametric: true },
't12': { name: 'Spearman Correlation', description: 'Measures monotonic relationship using ranks, robust to outliers.', use: 'Non-linear but consistent relationships.', example: 'Do income and happiness increase together?', parametric: false },
't13': { name: 'Chi-Square Test', description: 'Tests independence between two categorical variables.', use: 'Association between categories.', example: 'Is survival rate related to passenger class?', parametric: false },
't14': { name: "Fisher's Exact Test", description: 'Exact test for categorical association with small samples.', use: 'When expected frequencies are below 5.', example: 'Association in a 2x2 table with few observations.', parametric: false },
};
let selectedTest = $state(null);
function handleNodeClick(event) {
const nodeId = event.detail.node.id;
if (nodeId.startsWith('t')) {
selectedTest = testDescriptions[nodeId];
} else {
selectedTest = null;
}
}
</script>
<div class="app-container">
<h1>Statistical Test Decision Flowchart</h1>
<p class="subtitle">Svelte Version - Click on any blue test node to learn more</p>
<div class="main-content">
<div class="flowchart-container">
<SvelteFlow
{nodes}
{edges}
fitView
fitViewOptions={{ padding: 0.2, minZoom: 0.5, maxZoom: 1 }}
minZoom={0.3}
maxZoom={2}
onnodeclick={handleNodeClick}
>
<Controls />
<Background variant="dots" gap={12} size={1} />
<MiniMap />
</SvelteFlow>
</div>
<div class="info-panel">
{#if selectedTest}
<div class="test-info">
<h2>{selectedTest.name}</h2>
<span class="badge {selectedTest.parametric ? 'parametric' : 'non-parametric'}">
{selectedTest.parametric ? 'Parametric' : 'Non-Parametric'}
</span>
<div class="info-section">
<h3>Description</h3>
<p>{selectedTest.description}</p>
</div>
<div class="info-section">
<h3>When to Use</h3>
<p>{selectedTest.use}</p>
</div>
<div class="info-section">
<h3>Example</h3>
<p class="example">{selectedTest.example}</p>
</div>
</div>
{:else}
<div class="placeholder">
<h2>Select a Test</h2>
<p>Click on any <span class="highlight">blue test node</span> in the flowchart to see detailed information about that statistical test.</p>
<div class="legend">
<h3>Legend</h3>
<div class="legend-item">
<div class="legend-box start"></div>
<span>Start Point</span>
</div>
<div class="legend-item">
<div class="legend-box decision"></div>
<span>Decision Point</span>
</div>
<div class="legend-item">
<div class="legend-box test"></div>
<span>Statistical Test</span>
</div>
</div>
<div class="quick-ref">
<h3>Quick Reference</h3>
<table>
<thead>
<tr>
<th>Scenario</th>
<th>Parametric</th>
<th>Non-Parametric</th>
</tr>
</thead>
<tbody>
<tr><td>1 sample vs value</td><td>One-Sample T</td><td>Wilcoxon</td></tr>
<tr><td>2 independent groups</td><td>T-Test / Welch's</td><td>Mann-Whitney U</td></tr>
<tr><td>2 paired groups</td><td>Paired T-Test</td><td>Wilcoxon</td></tr>
<tr><td>3+ groups</td><td>ANOVA</td><td>Kruskal-Wallis</td></tr>
<tr><td>Correlation</td><td>Pearson</td><td>Spearman</td></tr>
<tr><td>Categorical</td><td>Chi-Square</td><td>Fisher's Exact</td></tr>
</tbody>
</table>
</div>
</div>
{/if}
</div>
</div>
</div>
<style>
:global(*) {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:global(body) {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: #f5f5f5;
}
.app-container {
min-height: 100vh;
padding: 20px;
}
h1 {
text-align: center;
color: #333;
margin-bottom: 5px;
font-size: 28px;
}
.subtitle {
text-align: center;
color: #666;
margin-bottom: 20px;
font-size: 14px;
}
.main-content {
display: flex;
gap: 20px;
height: calc(100vh - 120px);
min-height: 600px;
}
.flowchart-container {
flex: 1;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.info-panel {
width: 300px;
min-width: 300px;
max-width: 300px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
padding: 20px;
overflow-y: auto;
}
.test-info h2 {
color: #01579b;
margin-bottom: 10px;
font-size: 22px;
}
.badge {
display: inline-block;
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
margin-bottom: 20px;
}
.badge.parametric {
background: #e8f5e9;
color: #2e7d32;
}
.badge.non-parametric {
background: #fff3e0;
color: #e65100;
}
.info-section {
margin-bottom: 20px;
}
.info-section h3 {
color: #555;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
}
.info-section p {
color: #333;
line-height: 1.6;
font-size: 15px;
}
.info-section .example {
background: #f8f9fa;
padding: 12px;
border-radius: 8px;
border-left: 3px solid #01579b;
font-style: italic;
}
.placeholder h2 {
color: #333;
margin-bottom: 15px;
}
.placeholder > p {
color: #666;
line-height: 1.6;
margin-bottom: 30px;
}
.highlight {
background: #e1f5fe;
color: #01579b;
padding: 2px 6px;
border-radius: 4px;
font-weight: 500;
}
.legend {
margin-bottom: 30px;
}
.legend h3, .quick-ref h3 {
color: #555;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
}
.legend-item {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 10px;
}
.legend-box {
width: 24px;
height: 24px;
border-radius: 4px;
border: 2px solid;
}
.legend-box.start {
background: #c8e6c9;
border-color: #2e7d32;
border-radius: 12px;
}
.legend-box.decision {
background: #fff3e0;
border-color: #e65100;
}
.legend-box.test {
background: #e1f5fe;
border-color: #01579b;
}
.legend-item span {
color: #555;
font-size: 14px;
}
.quick-ref table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.quick-ref th {
background: #f5f5f5;
padding: 8px 6px;
text-align: left;
font-weight: 600;
color: #333;
border-bottom: 2px solid #ddd;
}
.quick-ref td {
padding: 8px 6px;
border-bottom: 1px solid #eee;
color: #555;
}
.quick-ref tr:hover td {
background: #f8f9fa;
}
</style>

View file

@ -0,0 +1,8 @@
import { mount } from 'svelte';
import App from './App.svelte';
const app = mount(App, {
target: document.getElementById('app'),
});
export default app;

View file

@ -0,0 +1,5 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
export default {
preprocess: vitePreprocess(),
};

View file

@ -0,0 +1,7 @@
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({
plugins: [svelte()],
base: '/tools/flowchart/',
});

2556
apps/homepage/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,22 @@
{
"name": "build-valuecurve-homepage",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.0",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"svelte": "^5.0.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.0.0",
"vite": "^6.0.0"
},
"type": "module"
}

View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

25
apps/homepage/src/app.css Normal file
View file

@ -0,0 +1,25 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
scroll-behavior: smooth;
}
body {
@apply antialiased;
}
}
@layer components {
.card-hover {
@apply transition-all duration-300 hover:shadow-xl hover:-translate-y-1;
}
.gradient-text {
@apply bg-clip-text text-transparent bg-gradient-to-r from-primary-600 to-primary-800;
}
}

View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="Build with AI - Data science guides, interactive tools, and insights" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -0,0 +1,38 @@
<script lang="ts">
interface Props {
href: string;
title: string;
description: string;
icon: string;
color: string;
}
let { href, title, description, icon, color }: Props = $props();
</script>
<a
{href}
class="group block p-6 bg-white rounded-2xl border border-gray-100 card-hover"
>
<div
class="w-12 h-12 rounded-xl flex items-center justify-center mb-4 transition-transform group-hover:scale-110"
style="background: {color}15;"
>
<span class="text-2xl">{icon}</span>
</div>
<h3 class="text-lg font-semibold text-gray-900 mb-2 group-hover:text-primary-600 transition-colors">
{title}
</h3>
<p class="text-gray-600 text-sm leading-relaxed">
{description}
</p>
<div class="mt-4 flex items-center text-primary-600 text-sm font-medium">
<span>Learn more</span>
<svg class="w-4 h-4 ml-1 transition-transform group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</div>
</a>

View file

@ -0,0 +1,14 @@
<footer class="bg-gray-50 border-t border-gray-100 py-12">
<div class="max-w-6xl mx-auto px-6">
<div class="flex flex-col md:flex-row items-center justify-between gap-4">
<div class="flex items-center gap-2">
<img src="/logo.png" alt="ValueCurve" class="h-6 w-auto" />
<span class="text-sm text-gray-600">Build ValueCurve</span>
</div>
<p class="text-sm text-gray-500">
Data science guides and tools for builders.
</p>
</div>
</div>
</footer>

View file

@ -0,0 +1,56 @@
<script lang="ts">
import { onMount } from 'svelte';
let visible = $state(false);
onMount(() => {
setTimeout(() => {
visible = true;
}, 100);
});
</script>
<section class="relative min-h-[80vh] flex items-center justify-center overflow-hidden">
<!-- Background gradient -->
<div class="absolute inset-0 bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50"></div>
<!-- Subtle grid pattern -->
<div
class="absolute inset-0 opacity-[0.03]"
style:background-image="url('data:image/svg+xml,%3Csvg width=%2760%27 height=%2760%27 viewBox=%270 0 60 60%27 xmlns=%27http://www.w3.org/2000/svg%27%3E%3Cg fill=%27none%27 fill-rule=%27evenodd%27%3E%3Cg fill=%27%23000%27%3E%3Cpath d=%27M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z%27/%3E%3C/g%3E%3C/g%3E%3C/svg%3E')"
></div>
<!-- Floating shapes -->
<div class="absolute top-20 left-10 w-72 h-72 bg-primary-200 rounded-full mix-blend-multiply filter blur-3xl opacity-30 animate-pulse"></div>
<div class="absolute bottom-20 right-10 w-72 h-72 bg-indigo-200 rounded-full mix-blend-multiply filter blur-3xl opacity-30 animate-pulse" style="animation-delay: 1s;"></div>
<div
class="relative z-10 text-center px-6 max-w-4xl mx-auto transition-all duration-1000 {visible
? 'opacity-100 translate-y-0'
: 'opacity-0 translate-y-8'}"
>
<h1 class="text-5xl md:text-7xl font-extrabold tracking-tight mb-6">
<span class="text-gray-900">Build with </span>
<span class="gradient-text">AI</span>
</h1>
<p class="text-xl md:text-2xl text-gray-600 font-light max-w-2xl mx-auto mb-10 leading-relaxed">
Data science guides, interactive tools, and insights to accelerate your journey.
</p>
<div class="flex flex-wrap justify-center gap-4">
<a
href="/guides/"
class="px-8 py-3 bg-primary-600 text-white rounded-full font-medium hover:bg-primary-700 transition-all shadow-lg shadow-primary-500/25 hover:shadow-xl hover:shadow-primary-500/30"
>
Explore Guides
</a>
<a
href="/tools/"
class="px-8 py-3 bg-white text-gray-700 rounded-full font-medium hover:bg-gray-50 transition-all border border-gray-200 shadow-sm"
>
Try Tools
</a>
</div>
</div>
</section>

View file

@ -0,0 +1,38 @@
<script lang="ts">
let scrolled = $state(false);
$effect(() => {
const handleScroll = () => {
scrolled = window.scrollY > 20;
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
});
</script>
<nav
class="fixed top-0 left-0 right-0 z-50 transition-all duration-300 {scrolled
? 'bg-white/90 backdrop-blur-md shadow-sm'
: 'bg-transparent'}"
>
<div class="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
<a href="/" class="flex items-center gap-2">
<img src="/logo.png" alt="ValueCurve" class="h-8 w-auto" />
</a>
<div class="flex items-center gap-6">
<a href="/guides/" class="text-gray-600 hover:text-gray-900 transition-colors text-sm font-medium">
Guides
</a>
<a href="/tools/" class="text-gray-600 hover:text-gray-900 transition-colors text-sm font-medium">
Tools
</a>
<a href="https://www.valuecurve.ai" target="_blank" rel="noopener" class="text-gray-600 hover:text-gray-900 transition-colors text-sm font-medium">
Newsletter
</a>
<a href="/notebooks/" class="text-gray-600 hover:text-gray-900 transition-colors text-sm font-medium">
Notebooks
</a>
</div>
</div>
</nav>

View file

@ -0,0 +1,93 @@
<script lang="ts">
import { onMount } from 'svelte';
interface Post {
title: string;
description: string;
link: string;
pubDate: string;
}
let posts: Post[] = $state([]);
let loading = $state(true);
let error = $state(false);
onMount(async () => {
try {
// Use production proxy in dev too (CORS)
const url = import.meta.env.DEV
? 'https://build.valuecurve.co/api/rss'
: '/api/rss';
const response = await fetch(url);
const xml = await response.text();
// Parse RSS XML
const items = xml.match(/<item>[\s\S]*?<\/item>/g) || [];
posts = items.slice(0, 4).map(item => ({
title: (item.match(/<title><!\[CDATA\[([\s\S]*?)\]\]><\/title>/)?.[1] ||
item.match(/<title>([\s\S]*?)<\/title>/)?.[1] || '').trim(),
description: (item.match(/<description><!\[CDATA\[([\s\S]*?)\]\]><\/description>/)?.[1] || '').trim(),
link: item.match(/<link>(.*?)<\/link>/)?.[1] || '',
pubDate: item.match(/<pubDate>(.*?)<\/pubDate>/)?.[1] || ''
}));
} catch (e) {
error = true;
console.error('Failed to fetch posts:', e);
} finally {
loading = false;
}
});
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('en-US', {
month: 'short', day: 'numeric', year: 'numeric'
});
}
function stripHtml(html: string): string {
return html.replace(/<[^>]*>/g, '').substring(0, 150) + '...';
}
</script>
<section class="py-20 px-6 bg-gray-50">
<div class="max-w-6xl mx-auto">
<div class="flex items-center justify-between mb-8">
<h2 class="text-2xl font-bold text-gray-900">Latest from Newsletter</h2>
<a href="https://www.valuecurve.ai"
class="text-primary-600 hover:text-primary-700 text-sm font-medium"
target="_blank" rel="noopener">
View all &rarr;
</a>
</div>
{#if loading}
<!-- Skeleton loader -->
<div class="grid md:grid-cols-2 gap-6">
{#each [1, 2, 3, 4] as _}
<div class="p-6 bg-white rounded-xl border border-gray-100 animate-pulse">
<div class="h-3 bg-gray-200 rounded w-20 mb-3"></div>
<div class="h-5 bg-gray-200 rounded w-3/4 mb-2"></div>
<div class="h-4 bg-gray-200 rounded w-full"></div>
</div>
{/each}
</div>
{:else if error}
<p class="text-gray-500 text-center py-8">
Unable to load posts. <a href="https://www.valuecurve.ai" class="text-primary-600 hover:underline" target="_blank" rel="noopener">Visit newsletter &rarr;</a>
</p>
{:else}
<div class="grid md:grid-cols-2 gap-6">
{#each posts as post}
<a href={post.link}
class="block p-6 bg-white rounded-xl border border-gray-100 hover:shadow-lg transition-shadow"
target="_blank" rel="noopener">
<p class="text-xs text-gray-500 mb-2">{formatDate(post.pubDate)}</p>
<h3 class="font-semibold text-gray-900 mb-2 line-clamp-2">{post.title}</h3>
<p class="text-sm text-gray-600 line-clamp-2">{stripHtml(post.description)}</p>
</a>
{/each}
</div>
{/if}
</div>
</section>

View file

@ -0,0 +1,84 @@
<script lang="ts">
interface Props {
href: string;
title: string;
description: string;
icon: string;
category: string;
status?: 'live' | 'coming-soon';
external?: boolean;
}
let { href, title, description, icon, category, status = 'live', external = false }: Props = $props();
function getCategoryColor(cat: string): string {
switch (cat.toUpperCase()) {
case 'INTERACTIVE':
return 'bg-purple-100 text-purple-700';
case 'STATISTICS':
return 'bg-blue-100 text-blue-700';
case 'VISUALIZATION':
return 'bg-green-100 text-green-700';
case 'ML':
return 'bg-orange-100 text-orange-700';
case 'EDA':
return 'bg-teal-100 text-teal-700';
case 'TOOL':
return 'bg-indigo-100 text-indigo-700';
default:
return 'bg-gray-100 text-gray-700';
}
}
</script>
{#if status === 'coming-soon'}
<div class="group block p-6 bg-white rounded-2xl border border-gray-100 opacity-75">
<div class="flex items-start justify-between mb-4">
<div class="w-12 h-12 rounded-xl bg-gray-100 flex items-center justify-center">
<span class="text-2xl grayscale">{icon}</span>
</div>
<span class="text-xs font-medium px-2 py-1 rounded-full bg-gray-100 text-gray-500">
Coming Soon
</span>
</div>
<h3 class="text-lg font-semibold text-gray-400 mb-2">
{title}
</h3>
<p class="text-gray-400 text-sm leading-relaxed">
{description}
</p>
</div>
{:else}
<a
{href}
class="group block p-6 bg-white rounded-2xl border border-gray-100 card-hover"
target={external ? '_blank' : undefined}
rel={external ? 'noopener noreferrer' : undefined}
>
<div class="flex items-start justify-between mb-4">
<div class="w-12 h-12 rounded-xl bg-primary-50 flex items-center justify-center transition-transform group-hover:scale-110">
<span class="text-2xl">{icon}</span>
</div>
<span class="text-xs font-medium px-2 py-1 rounded-full {getCategoryColor(category)}">
{category}
</span>
</div>
<h3 class="text-lg font-semibold text-gray-900 mb-2 group-hover:text-primary-600 transition-colors">
{title}
</h3>
<p class="text-gray-600 text-sm leading-relaxed">
{description}
</p>
<div class="mt-4 flex items-center text-primary-600 text-sm font-medium">
<span>{external ? 'Open' : 'Explore'}</span>
<svg class="w-4 h-4 ml-1 transition-transform group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</div>
</a>
{/if}

View file

@ -0,0 +1,5 @@
<script>
import '../app.css';
</script>
<slot />

View file

@ -0,0 +1,2 @@
export const prerender = true;
export const trailingSlash = 'always';

View file

@ -0,0 +1,77 @@
<script lang="ts">
import Navbar from '$lib/components/Navbar.svelte';
import Hero from '$lib/components/Hero.svelte';
import FeatureCard from '$lib/components/FeatureCard.svelte';
import NewsletterSection from '$lib/components/NewsletterSection.svelte';
import Footer from '$lib/components/Footer.svelte';
const features = [
{
href: '/guides/statistical-tests/',
title: 'Statistical Tests',
description: 'A comprehensive guide to choosing the right statistical test. Interactive fishbone diagram to navigate parametric vs non-parametric options.',
icon: '📊',
color: '#1565c0'
},
{
href: '/tools/privacy-scanner/',
title: 'Privacy Scanner',
description: 'Detect and redact personally identifiable information (PII) from text and files. Supports 30+ PII types including emails, SSN, API keys.',
icon: '🔒',
color: '#7b1fa2'
},
{
href: '/notebooks/',
title: 'Exploratory Data Analysis',
description: 'Exploratory data analysis using the Gapminder dataset. Learn data wrangling, visualization, and insights extraction.',
icon: '🌍',
color: '#00897b'
}
];
</script>
<svelte:head>
<title>Build with AI | ValueCurve</title>
<meta name="description" content="Data science guides, interactive tools, and insights to accelerate your journey." />
</svelte:head>
<div class="min-h-screen">
<Navbar />
<main>
<Hero />
<!-- Features Section -->
<section class="py-20 px-6 bg-white">
<div class="max-w-6xl mx-auto">
<div class="text-center mb-12">
<h2 class="text-3xl font-bold text-gray-900 mb-4">Featured Resources</h2>
<p class="text-gray-600 max-w-2xl mx-auto">
Practical guides and interactive tools to help you make data-driven decisions.
</p>
</div>
<div class="grid md:grid-cols-3 gap-6 max-w-5xl mx-auto">
{#each features as feature}
<FeatureCard {...feature} />
{/each}
</div>
</div>
</section>
<!-- Newsletter Section -->
<NewsletterSection />
<!-- Coming Soon Section -->
<section class="py-16 px-6 bg-gradient-to-b from-white to-gray-50">
<div class="max-w-6xl mx-auto text-center">
<h3 class="text-2xl font-semibold text-gray-900 mb-4">More Coming Soon</h3>
<p class="text-gray-600 max-w-xl mx-auto">
We're building more guides and interactive tools for machine learning, data visualization, and AI development.
</p>
</div>
</section>
</main>
<Footer />
</div>

View file

@ -0,0 +1,111 @@
<script lang="ts">
import Navbar from '$lib/components/Navbar.svelte';
import Footer from '$lib/components/Footer.svelte';
import ResourceCard from '$lib/components/ResourceCard.svelte';
const guides = [
{
href: '/guides/statistical-tests/',
title: 'Statistical Tests',
description: 'A comprehensive guide to choosing the right statistical test. Interactive fishbone diagram to navigate parametric vs non-parametric options.',
icon: '📊',
category: 'STATISTICS',
status: 'live' as const
},
{
href: '#',
title: 'Data Visualization',
description: 'Learn the principles of effective data visualization. From choosing the right chart type to creating compelling visual narratives.',
icon: '📈',
category: 'VISUALIZATION',
status: 'coming-soon' as const
},
{
href: '#',
title: 'ML Model Selection',
description: 'A practical guide to selecting the right machine learning model for your problem. Decision trees, neural networks, and more.',
icon: '🤖',
category: 'ML',
status: 'coming-soon' as const
},
{
href: '#',
title: 'Feature Engineering',
description: 'Transform raw data into meaningful features. Best practices for numerical, categorical, and text data.',
icon: '🔧',
category: 'ML',
status: 'coming-soon' as const
}
];
let searchQuery = $state('');
let selectedCategory = $state('all');
const categories = ['all', 'STATISTICS', 'VISUALIZATION', 'ML'];
const filteredGuides = $derived(
guides.filter(guide => {
const matchesSearch = guide.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
guide.description.toLowerCase().includes(searchQuery.toLowerCase());
const matchesCategory = selectedCategory === 'all' || guide.category === selectedCategory;
return matchesSearch && matchesCategory;
})
);
</script>
<svelte:head>
<title>Guides | Build with AI</title>
<meta name="description" content="In-depth guides for statistics, data visualization, and machine learning." />
</svelte:head>
<div class="min-h-screen bg-gradient-to-b from-slate-50 to-white">
<Navbar />
<main class="pt-24 pb-16 px-6">
<div class="max-w-6xl mx-auto">
<!-- Header -->
<div class="text-center mb-12">
<h1 class="text-4xl font-bold text-gray-900 mb-4">Guides</h1>
<p class="text-gray-600 max-w-2xl mx-auto">
In-depth guides to help you understand statistics, data visualization, and machine learning concepts.
</p>
</div>
<!-- Search & Filters -->
<div class="flex flex-col sm:flex-row gap-4 mb-8">
<div class="flex-1">
<input
type="text"
placeholder="Search guides..."
bind:value={searchQuery}
class="w-full px-4 py-2.5 border border-gray-200 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-all"
/>
</div>
<select
bind:value={selectedCategory}
class="px-4 py-2.5 border border-gray-200 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white"
>
<option value="all">All Categories</option>
{#each categories.filter(c => c !== 'all') as category}
<option value={category}>{category}</option>
{/each}
</select>
</div>
<!-- Guides Grid -->
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{#each filteredGuides as guide}
<ResourceCard {...guide} />
{/each}
</div>
{#if filteredGuides.length === 0}
<div class="text-center py-12">
<p class="text-gray-500">No guides found matching your criteria.</p>
</div>
{/if}
</div>
</main>
<Footer />
</div>

View file

@ -0,0 +1,163 @@
<script lang="ts">
import Navbar from '$lib/components/Navbar.svelte';
import Footer from '$lib/components/Footer.svelte';
interface Notebook {
title: string;
description: string;
category: string;
colabUrl: string;
icon: string;
}
const notebooks: Notebook[] = [
{
title: 'Exploratory Data Analysis',
description: 'Exploratory data analysis using the Gapminder dataset. Learn data wrangling, visualization, and insights extraction.',
category: 'EDA',
colabUrl: 'https://colab.research.google.com/github/valuecurve/notebooks/blob/main/eda-gapminder.ipynb',
icon: '🌍'
},
{
title: 'Statistical Tests Practice',
description: 'Hands-on practice with t-tests, ANOVA, chi-square, and correlation analysis using real datasets.',
category: 'STATISTICS',
colabUrl: 'https://colab.research.google.com/github/valuecurve/notebooks/blob/main/statistical-tests.ipynb',
icon: '📊'
},
{
title: 'Linear Regression Deep Dive',
description: 'From simple to multiple regression. Understand assumptions, diagnostics, and interpretation.',
category: 'ML',
colabUrl: '#',
icon: '📈'
},
{
title: 'Classification Models',
description: 'Logistic regression, decision trees, and random forests. Compare model performance and interpret results.',
category: 'ML',
colabUrl: '#',
icon: '🎯'
}
];
let searchQuery = $state('');
let selectedCategory = $state('all');
const categories = ['all', 'EDA', 'STATISTICS', 'ML'];
const filteredNotebooks = $derived(
notebooks.filter(nb => {
const matchesSearch = nb.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
nb.description.toLowerCase().includes(searchQuery.toLowerCase());
const matchesCategory = selectedCategory === 'all' || nb.category === selectedCategory;
return matchesSearch && matchesCategory;
})
);
function getCategoryColor(cat: string): string {
switch (cat.toUpperCase()) {
case 'EDA':
return 'bg-teal-100 text-teal-700';
case 'STATISTICS':
return 'bg-blue-100 text-blue-700';
case 'ML':
return 'bg-orange-100 text-orange-700';
default:
return 'bg-gray-100 text-gray-700';
}
}
</script>
<svelte:head>
<title>Notebooks | Build with AI</title>
<meta name="description" content="Interactive Jupyter notebooks for hands-on learning. Open directly in Google Colab." />
</svelte:head>
<div class="min-h-screen bg-gradient-to-b from-slate-50 to-white">
<Navbar />
<main class="pt-24 pb-16 px-6">
<div class="max-w-6xl mx-auto">
<!-- Header -->
<div class="text-center mb-12">
<h1 class="text-4xl font-bold text-gray-900 mb-4">Notebooks</h1>
<p class="text-gray-600 max-w-2xl mx-auto">
Interactive Jupyter notebooks for hands-on learning. Open directly in Google Colab - no setup required.
</p>
</div>
<!-- Search & Filters -->
<div class="flex flex-col sm:flex-row gap-4 mb-8">
<div class="flex-1">
<input
type="text"
placeholder="Search notebooks..."
bind:value={searchQuery}
class="w-full px-4 py-2.5 border border-gray-200 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-all"
/>
</div>
<select
bind:value={selectedCategory}
class="px-4 py-2.5 border border-gray-200 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white"
>
<option value="all">All Categories</option>
{#each categories.filter(c => c !== 'all') as category}
<option value={category}>{category}</option>
{/each}
</select>
</div>
<!-- Notebooks Grid -->
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{#each filteredNotebooks as notebook}
{@const isAvailable = notebook.colabUrl !== '#'}
<div class="group block p-6 bg-white rounded-2xl border border-gray-100 {isAvailable ? 'card-hover' : 'opacity-75'}">
<div class="flex items-start justify-between mb-4">
<div class="w-12 h-12 rounded-xl {isAvailable ? 'bg-primary-50' : 'bg-gray-100'} flex items-center justify-center {isAvailable ? 'transition-transform group-hover:scale-110' : ''}">
<span class="text-2xl {isAvailable ? '' : 'grayscale'}">{notebook.icon}</span>
</div>
<span class="text-xs font-medium px-2 py-1 rounded-full {getCategoryColor(notebook.category)}">
{notebook.category}
</span>
</div>
<h3 class="text-lg font-semibold {isAvailable ? 'text-gray-900 group-hover:text-primary-600' : 'text-gray-400'} mb-2 transition-colors">
{notebook.title}
</h3>
<p class="{isAvailable ? 'text-gray-600' : 'text-gray-400'} text-sm leading-relaxed mb-4">
{notebook.description}
</p>
{#if isAvailable}
<a
href={notebook.colabUrl}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-4 py-2 bg-yellow-400 hover:bg-yellow-500 text-gray-900 rounded-lg text-sm font-medium transition-colors"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.372 0 0 5.372 0 12s5.372 12 12 12 12-5.372 12-12S18.628 0 12 0zm0 2.4c5.302 0 9.6 4.298 9.6 9.6s-4.298 9.6-9.6 9.6S2.4 17.302 2.4 12 6.698 2.4 12 2.4z"/>
</svg>
Open in Colab
</a>
{:else}
<span class="inline-flex items-center gap-2 px-4 py-2 bg-gray-100 text-gray-400 rounded-lg text-sm font-medium">
Coming Soon
</span>
{/if}
</div>
{/each}
</div>
{#if filteredNotebooks.length === 0}
<div class="text-center py-12">
<p class="text-gray-500">No notebooks found matching your criteria.</p>
</div>
{/if}
</div>
</main>
<Footer />
</div>

View file

@ -0,0 +1,135 @@
<script lang="ts">
import Navbar from '$lib/components/Navbar.svelte';
import Footer from '$lib/components/Footer.svelte';
import ResourceCard from '$lib/components/ResourceCard.svelte';
const tools = [
{
href: '/tools/privacy-scanner/',
title: 'Privacy Scanner',
description: 'Detect and redact personally identifiable information (PII) from text and files. Supports 30+ PII types including emails, SSN, API keys.',
icon: '🔒',
category: 'INTERACTIVE',
status: 'live' as const
},
{
href: '/tools/eda-gapminder/',
title: 'EDA Gapminder',
description: 'Explore global development data with interactive visualizations. GDP, life expectancy, and population trends from 1952-2007.',
icon: '🌍',
category: 'VISUALIZATION',
status: 'live' as const
},
{
href: '/tools/house-predictor/',
title: 'House Price Predictor',
description: 'Seattle/King County house price prediction with ML. Explore 21,613 houses on an interactive map and get instant price estimates.',
icon: '🏠',
category: 'VISUALIZATION',
status: 'live' as const
},
{
href: '/tools/flowchart/',
title: 'Decision Flowchart',
description: 'Interactive decision tree to help you select the appropriate statistical test based on your data type and research question.',
icon: '🔀',
category: 'FRAMEWORK',
status: 'live' as const
},
{
href: '#',
title: 'Inference Estimator',
description: 'Estimate inference costs and latency for LLM deployments across different providers and model sizes.',
icon: '⚡',
category: 'TOOL',
status: 'coming-soon' as const
},
{
href: '#',
title: 'Cost Tracker',
description: 'Track and compare API costs across OpenAI, Anthropic, and other LLM providers. Optimize your AI spending.',
icon: '💰',
category: 'TOOL',
status: 'coming-soon' as const
},
{
href: '#',
title: 'Drift Monitor',
description: 'Monitor model performance and detect data drift in production ML systems. Get alerts when models degrade.',
icon: '📉',
category: 'TOOL',
status: 'coming-soon' as const
}
];
let searchQuery = $state('');
let selectedCategory = $state('all');
const categories = ['all', 'INTERACTIVE', 'VISUALIZATION', 'FRAMEWORK'];
const filteredTools = $derived(
tools.filter(tool => {
const matchesSearch = tool.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
tool.description.toLowerCase().includes(searchQuery.toLowerCase());
const matchesCategory = selectedCategory === 'all' || tool.category === selectedCategory;
return matchesSearch && matchesCategory;
})
);
</script>
<svelte:head>
<title>Tools | Build with AI</title>
<meta name="description" content="Interactive tools for data science, machine learning, and AI development." />
</svelte:head>
<div class="min-h-screen bg-gradient-to-b from-slate-50 to-white">
<Navbar />
<main class="pt-24 pb-16 px-6">
<div class="max-w-6xl mx-auto">
<!-- Header -->
<div class="text-center mb-12">
<h1 class="text-4xl font-bold text-gray-900 mb-4">Tools</h1>
<p class="text-gray-600 max-w-2xl mx-auto">
Interactive tools to help you with data science, machine learning, and AI development.
</p>
</div>
<!-- Search & Filters -->
<div class="flex flex-col sm:flex-row gap-4 mb-8">
<div class="flex-1">
<input
type="text"
placeholder="Search tools..."
bind:value={searchQuery}
class="w-full px-4 py-2.5 border border-gray-200 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-all"
/>
</div>
<select
bind:value={selectedCategory}
class="px-4 py-2.5 border border-gray-200 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white"
>
<option value="all">All Categories</option>
{#each categories.filter(c => c !== 'all') as category}
<option value={category}>{category}</option>
{/each}
</select>
</div>
<!-- Tools Grid -->
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{#each filteredTools as tool}
<ResourceCard {...tool} />
{/each}
</div>
{#if filteredTools.length === 0}
<div class="text-center py-12">
<p class="text-gray-500">No tools found matching your criteria.</p>
</div>
{/if}
</div>
</main>
<Footer />
</div>

View file

@ -0,0 +1,548 @@
<script lang="ts">
import Navbar from '$lib/components/Navbar.svelte';
import Footer from '$lib/components/Footer.svelte';
// API Base - uses env var for local dev, defaults to cockpit for production
const API_BASE = (import.meta.env.VITE_API_URL || 'https://cockpit.valuecurve.co') + '/api/v1';
// State
let loading = $state(false);
let error = $state('');
let metadata: any = $state(null);
let data: any[] = $state([]);
let statistics: any = $state(null);
// Filters
let selectedYear = $state(1952);
let selectedContinent = $state('');
let selectedMetric: 'lifeExp' | 'pop' | 'gdpPercap' = $state('gdpPercap');
let topN = $state(15);
// Animation state
let isPlaying = $state(false);
let animationInterval: any = $state(null);
let years: number[] = $state([]);
// Tabs
let activeTab: 'scatter' | 'barrace' | 'trends' = $state('scatter');
// Chart containers
let scatterContainer: HTMLDivElement;
let barRaceContainer: HTMLDivElement;
let trendsContainer: HTMLDivElement;
// Plotly reference
let Plotly: any = $state(null);
let plotlyLoaded = $state(false);
// Load Plotly on mount
$effect(() => {
if (typeof window !== 'undefined' && !plotlyLoaded) {
const script = document.createElement('script');
script.src = 'https://cdn.plot.ly/plotly-2.27.0.min.js';
script.onload = () => {
Plotly = (window as any).Plotly;
plotlyLoaded = true;
loadData();
};
document.head.appendChild(script);
}
});
async function loadData() {
loading = true;
error = '';
try {
// Load metadata
const metaRes = await fetch(`${API_BASE}/eda/metadata`);
metadata = await metaRes.json();
years = metadata.years;
// Load initial data
await updateCharts();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load data';
} finally {
loading = false;
}
}
async function updateCharts() {
if (!Plotly) return;
try {
// Fetch filtered data
const params = new URLSearchParams();
if (selectedYear) params.append('year', selectedYear.toString());
if (selectedContinent) params.append('continent', selectedContinent);
const dataRes = await fetch(`${API_BASE}/eda/data?${params}`);
const result = await dataRes.json();
data = result.data;
// Fetch statistics
const statsRes = await fetch(
`${API_BASE}/eda/statistics?column=${selectedMetric}&group_by=continent&year=${selectedYear}`
);
statistics = await statsRes.json();
// Update active chart (small delay to ensure DOM is ready)
setTimeout(() => {
if (activeTab === 'scatter') renderScatterPlot();
else if (activeTab === 'barrace') renderBarRace();
else if (activeTab === 'trends') renderTrends();
}, 50);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update charts';
}
}
function renderScatterPlot() {
if (!scatterContainer || !Plotly || data.length === 0) return;
const continents = [...new Set(data.map((d) => d.continent))];
// Plotly Express default color sequence
const colors: Record<string, string> = {
Africa: '#636EFA',
Americas: '#EF553B',
Asia: '#00CC96',
Europe: '#AB63FA',
Oceania: '#FFA15A'
};
// Calculate max population for scaling (like Plotly Express does)
const maxPop = Math.max(...data.map((d) => d.pop));
const sizeMax = 60; // Match original Streamlit app size_max=60
const traces = continents.map((continent) => {
const filtered = data.filter((d) => d.continent === continent);
return {
x: filtered.map((d) => d.gdpPercap),
y: filtered.map((d) => d.lifeExp),
mode: 'markers',
name: continent,
text: filtered.map((d) => `${d.country}<br>Pop: ${(d.pop / 1e6).toFixed(1)}M`),
marker: {
// Plotly Express uses sqrt scaling with sizeref
size: filtered.map((d) => d.pop),
sizemode: 'area',
sizeref: (2 * maxPop) / (sizeMax * sizeMax),
sizemin: 4,
color: colors[continent],
opacity: 0.7,
line: { width: 1, color: 'white' }
},
hovertemplate:
'<b>%{text}</b><br>GDP: $%{x:,.0f}<br>Life Exp: %{y:.1f} years<extra></extra>'
};
});
const layout = {
title: `GDP per Capita vs Life Expectancy (${selectedYear})`,
xaxis: {
title: 'GDP per Capita (log scale)',
type: 'log',
range: [2.5, 5.2]
},
yaxis: {
title: 'Life Expectancy (years)',
range: [20, 90]
},
showlegend: true,
legend: { x: 0.02, y: 0.98 },
hovermode: 'closest',
paper_bgcolor: 'rgba(0,0,0,0)',
plot_bgcolor: 'rgba(0,0,0,0)',
font: { family: 'Inter, system-ui, sans-serif' }
};
Plotly.newPlot(scatterContainer, traces, layout, { responsive: true });
}
async function renderBarRace() {
if (!barRaceContainer || !Plotly) return;
try {
const res = await fetch(
`${API_BASE}/eda/ranking?year=${selectedYear}&metric=${selectedMetric}&top_n=${topN}`
);
const result = await res.json();
const colors: Record<string, string> = {
Africa: '#636EFA',
Americas: '#EF553B',
Asia: '#00CC96',
Europe: '#AB63FA',
Oceania: '#FFA15A'
};
const metricLabels: Record<string, string> = {
gdpPercap: 'GDP per Capita ($)',
lifeExp: 'Life Expectancy (years)',
pop: 'Population'
};
const sortedData = result.data.sort(
(a: any, b: any) => a[selectedMetric] - b[selectedMetric]
);
const trace = {
type: 'bar',
x: sortedData.map((d: any) => d[selectedMetric]),
y: sortedData.map((d: any) => d.country),
orientation: 'h',
marker: {
color: sortedData.map((d: any) => colors[d.continent])
},
text: sortedData.map((d: any) =>
selectedMetric === 'pop'
? `${(d[selectedMetric] / 1e6).toFixed(1)}M`
: selectedMetric === 'gdpPercap'
? `$${d[selectedMetric].toFixed(0)}`
: d[selectedMetric].toFixed(1)
),
textposition: 'outside',
hovertemplate: '<b>%{y}</b><br>%{x:,.0f}<extra></extra>'
};
const layout = {
title: `Top ${topN} Countries by ${metricLabels[selectedMetric]} (${selectedYear})`,
xaxis: { title: metricLabels[selectedMetric] },
yaxis: { automargin: true },
paper_bgcolor: 'rgba(0,0,0,0)',
plot_bgcolor: 'rgba(0,0,0,0)',
font: { family: 'Inter, system-ui, sans-serif' },
margin: { l: 150 }
};
Plotly.newPlot(barRaceContainer, [trace], layout, { responsive: true });
} catch (e) {
console.error('Bar race error:', e);
}
}
async function renderTrends() {
if (!trendsContainer || !Plotly) return;
try {
// Get top 10 countries by latest value
const res = await fetch(
`${API_BASE}/eda/timeseries?metric=${selectedMetric}&top_n=10${selectedContinent ? `&continent=${selectedContinent}` : ''}`
);
const result = await res.json();
const countries = [...new Set(result.data.map((d: any) => d.country))];
const traces = countries.map((country) => {
const countryData = result.data
.filter((d: any) => d.country === country)
.sort((a: any, b: any) => a.year - b.year);
return {
x: countryData.map((d: any) => d.year),
y: countryData.map((d: any) => d[selectedMetric]),
mode: 'lines+markers',
name: country,
hovertemplate: `<b>${country}</b><br>Year: %{x}<br>Value: %{y:,.0f}<extra></extra>`
};
});
const metricLabels: Record<string, string> = {
gdpPercap: 'GDP per Capita ($)',
lifeExp: 'Life Expectancy (years)',
pop: 'Population'
};
const layout = {
title: `${metricLabels[selectedMetric]} Over Time (Top 10)`,
xaxis: { title: 'Year', dtick: 5 },
yaxis: { title: metricLabels[selectedMetric] },
showlegend: true,
legend: { x: 1.02, y: 1 },
paper_bgcolor: 'rgba(0,0,0,0)',
plot_bgcolor: 'rgba(0,0,0,0)',
font: { family: 'Inter, system-ui, sans-serif' }
};
Plotly.newPlot(trendsContainer, traces, layout, { responsive: true });
} catch (e) {
console.error('Trends error:', e);
}
}
// Animation controls
function playAnimation() {
if (isPlaying) {
stopAnimation();
return;
}
isPlaying = true;
let yearIndex = years.indexOf(selectedYear);
animationInterval = setInterval(() => {
yearIndex = (yearIndex + 1) % years.length;
selectedYear = years[yearIndex];
updateCharts();
}, 1000);
}
function stopAnimation() {
isPlaying = false;
if (animationInterval) {
clearInterval(animationInterval);
animationInterval = null;
}
}
// Reactive updates - track filter changes
let prevYear = $state(selectedYear);
let prevContinent = $state(selectedContinent);
let prevMetric = $state(selectedMetric);
let prevTopN = $state(topN);
$effect(() => {
// Only trigger when filters actually change (not on initial load)
if (plotlyLoaded && data.length > 0) {
if (prevYear !== selectedYear || prevContinent !== selectedContinent || prevMetric !== selectedMetric || prevTopN !== topN) {
prevYear = selectedYear;
prevContinent = selectedContinent;
prevMetric = selectedMetric;
prevTopN = topN;
updateCharts();
}
}
});
// Handle tab changes
let prevTab = $state(activeTab);
$effect(() => {
if (plotlyLoaded && activeTab !== prevTab) {
prevTab = activeTab;
// Stop animation when switching to trends tab
if (activeTab === 'trends' && isPlaying) {
stopAnimation();
}
setTimeout(() => {
if (activeTab === 'scatter') renderScatterPlot();
else if (activeTab === 'barrace') renderBarRace();
else if (activeTab === 'trends') renderTrends();
}, 100);
}
});
</script>
<svelte:head>
<title>EDA Gapminder | Build with AI</title>
<meta name="description" content="Explore global development data: GDP, life expectancy, and population trends from 1952-2007." />
</svelte:head>
<div class="min-h-screen bg-gradient-to-b from-slate-50 to-white">
<Navbar />
<main class="pt-20 pb-10 px-5">
<div class="max-w-7xl mx-auto">
<!-- Header -->
<div class="mb-5 flex items-center justify-between">
<div class="flex items-center gap-4">
<a href="/tools/" class="text-sm text-primary-600 hover:text-primary-700">&larr; Tools</a>
<h1 class="text-2xl font-bold text-gray-900">EDA Gapminder</h1>
<span class="text-gray-400 text-sm hidden sm:inline">|</span>
<p class="text-gray-500 text-sm hidden sm:inline">Explore global development data (1952-2007)</p>
</div>
</div>
{#if error}
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-4">
{error}
</div>
{/if}
<div class="grid grid-cols-1 lg:grid-cols-4 gap-4">
<!-- Filters Panel -->
<div class="bg-white rounded-xl border border-gray-100 shadow-sm p-4">
<h3 class="text-sm font-semibold text-gray-700 mb-4">Filters</h3>
<!-- Year Slider (disabled on Line Trends) -->
<div class="mb-4 {activeTab === 'trends' ? 'opacity-50' : ''}">
<label class="block text-xs font-medium text-gray-600 mb-2">
Year: <span class="text-primary-600 font-bold">{selectedYear}</span>
{#if activeTab === 'trends'}
<span class="text-gray-400">(N/A for trends)</span>
{/if}
</label>
<input
type="range"
min="1952"
max="2007"
step="5"
bind:value={selectedYear}
disabled={activeTab === 'trends'}
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer disabled:cursor-not-allowed accent-primary-600"
/>
<div class="flex justify-between text-xs text-gray-500 mt-1">
<span>1952</span>
<span>2007</span>
</div>
</div>
<!-- Play/Pause Animation (only for Scatter/Bar Race) -->
{#if activeTab !== 'trends'}
<button
onclick={playAnimation}
class="w-full mb-4 px-4 py-2 rounded-lg text-sm font-medium transition-colors
{isPlaying
? 'bg-red-100 text-red-700 hover:bg-red-200'
: 'bg-primary-100 text-primary-700 hover:bg-primary-200'}"
>
{isPlaying ? 'Stop Animation' : 'Play Timeline'}
</button>
{:else}
<div class="mb-4 px-4 py-2 rounded-lg text-sm text-gray-500 bg-gray-100 text-center">
Line Trends shows all years
</div>
{/if}
<!-- Continent Filter -->
<div class="mb-4">
<label class="block text-xs font-medium text-gray-600 mb-2">Continent</label>
<select bind:value={selectedContinent} class="w-full px-3 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 text-sm">
<option value="">All Continents</option>
{#if metadata}
{#each metadata.continents as continent}
<option value={continent}>{continent}</option>
{/each}
{/if}
</select>
</div>
<!-- Metric Selector -->
<div class="mb-4">
<label class="block text-xs font-medium text-gray-600 mb-2">Metric</label>
<select bind:value={selectedMetric} class="w-full px-3 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 text-sm">
<option value="gdpPercap">GDP per Capita</option>
<option value="lifeExp">Life Expectancy</option>
<option value="pop">Population</option>
</select>
</div>
<!-- Top N / Countries display -->
<div class="mb-4">
{#if activeTab === 'barrace'}
<label for="topn-slider" class="block text-xs font-medium text-gray-600 mb-2">
Top N Countries: <span class="text-primary-600 font-bold">{topN}</span>
</label>
<input
id="topn-slider"
type="range"
min="5"
max="25"
step="5"
bind:value={topN}
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-primary-600"
/>
{:else}
<label class="block text-xs font-medium text-gray-600 mb-2">
Countries: <span class="text-primary-600 font-bold">{data.length || 142}</span>
</label>
<div class="text-xs text-gray-400">All countries displayed</div>
{/if}
</div>
<!-- Statistics Panel -->
{#if statistics}
<div class="border-t border-gray-200 pt-4 mt-4">
<h4 class="text-xs font-semibold text-gray-700 mb-3">
Statistics ({selectedYear})
</h4>
<div class="space-y-2 text-xs">
<div class="flex justify-between">
<span class="text-gray-500">Mean:</span>
<span class="font-medium">{statistics.mean.toFixed(2)}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">Median:</span>
<span class="font-medium">{statistics.median.toFixed(2)}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">Std Dev:</span>
<span class="font-medium">{statistics.std.toFixed(2)}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">Min:</span>
<span class="font-medium">{statistics.min.toFixed(2)}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">Max:</span>
<span class="font-medium">{statistics.max.toFixed(2)}</span>
</div>
</div>
</div>
{/if}
</div>
<!-- Main Visualization Area -->
<div class="lg:col-span-3 bg-white rounded-xl border border-gray-100 shadow-sm p-4">
<!-- Tabs -->
<div class="flex border-b border-gray-200 mb-4">
<button
class="px-4 py-2 text-sm font-medium transition-colors
{activeTab === 'scatter'
? 'text-primary-600 border-b-2 border-primary-600'
: 'text-gray-500 hover:text-gray-700'}"
onclick={() => (activeTab = 'scatter')}
>
Scatter Plot
</button>
<button
class="px-4 py-2 text-sm font-medium transition-colors
{activeTab === 'barrace'
? 'text-primary-600 border-b-2 border-primary-600'
: 'text-gray-500 hover:text-gray-700'}"
onclick={() => (activeTab = 'barrace')}
>
Bar Chart Race
</button>
<button
class="px-4 py-2 text-sm font-medium transition-colors
{activeTab === 'trends'
? 'text-primary-600 border-b-2 border-primary-600'
: 'text-gray-500 hover:text-gray-700'}"
onclick={() => (activeTab = 'trends')}
>
Line Trends
</button>
</div>
<!-- Chart Container -->
{#if loading}
<div class="flex items-center justify-center h-96">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
</div>
{:else}
<div class="h-[500px]">
{#if activeTab === 'scatter'}
<div bind:this={scatterContainer} class="w-full h-full"></div>
{:else if activeTab === 'barrace'}
<div bind:this={barRaceContainer} class="w-full h-full"></div>
{:else if activeTab === 'trends'}
<div bind:this={trendsContainer} class="w-full h-full"></div>
{/if}
</div>
{/if}
<!-- Data Summary -->
{#if data.length > 0}
<div class="mt-4 pt-4 border-t border-gray-200">
<p class="text-xs text-gray-500">
Showing {data.length} records for {selectedYear}
{selectedContinent ? ` in ${selectedContinent}` : ''}
</p>
</div>
{/if}
</div>
</div>
</div>
</main>
<Footer />
</div>

View file

@ -0,0 +1,576 @@
<script lang="ts">
import Navbar from '$lib/components/Navbar.svelte';
import Footer from '$lib/components/Footer.svelte';
// API Base - uses env var for local dev, defaults to cockpit for production
const API_BASE = (import.meta.env.VITE_API_URL || 'https://cockpit.valuecurve.co') + '/api/v1';
// State
let loading = $state(false);
let error = $state('');
let metadata: any = $state(null);
let houseData: any[] = $state([]);
let statistics: any = $state(null);
let prediction: any = $state(null);
// Filters
let minPrice = $state(0);
let maxPrice = $state(8000000);
let minBedrooms = $state(0);
let maxBedrooms = $state(10);
let waterfrontOnly = $state(false);
let selectedZipcode = $state('');
// Prediction inputs
let predBedrooms = $state(3);
let predBathrooms = $state(2);
let predSqft = $state(2000);
let predAge = $state(10);
// Tabs
let activeTab: 'map' | 'distribution' | 'correlation' = $state('map');
// Chart containers
let mapContainer: HTMLDivElement;
let distContainer: HTMLDivElement;
let corrContainer: HTMLDivElement;
// Plotly reference
let Plotly: any = $state(null);
let plotlyLoaded = $state(false);
// Load Plotly on mount
$effect(() => {
if (typeof window !== 'undefined' && !plotlyLoaded) {
const script = document.createElement('script');
script.src = 'https://cdn.plot.ly/plotly-2.27.0.min.js';
script.onload = () => {
Plotly = (window as any).Plotly;
plotlyLoaded = true;
loadData();
};
document.head.appendChild(script);
}
});
async function loadData() {
loading = true;
error = '';
try {
// Load metadata
const metaRes = await fetch(`${API_BASE}/house/metadata`);
metadata = await metaRes.json();
// Load initial data and charts
await updateCharts();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load data';
} finally {
loading = false;
}
}
async function updateCharts() {
if (!Plotly) return;
try {
// Fetch filtered house data
const params = new URLSearchParams();
if (minPrice > 0) params.append('min_price', minPrice.toString());
if (maxPrice < 8000000) params.append('max_price', maxPrice.toString());
if (minBedrooms > 0) params.append('min_bedrooms', minBedrooms.toString());
if (maxBedrooms < 10) params.append('max_bedrooms', maxBedrooms.toString());
if (waterfrontOnly) params.append('waterfront', 'true');
if (selectedZipcode) params.append('zipcode', selectedZipcode);
params.append('sample_size', '2000');
const dataRes = await fetch(`${API_BASE}/house/data?${params}`);
const result = await dataRes.json();
houseData = result.data;
// Fetch statistics
const statsRes = await fetch(`${API_BASE}/house/statistics`);
statistics = await statsRes.json();
// Update active chart (small delay to ensure DOM is ready)
setTimeout(() => {
if (activeTab === 'map') renderMap();
else if (activeTab === 'distribution') renderDistribution();
else if (activeTab === 'correlation') renderCorrelation();
}, 50);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update charts';
}
}
function renderMap() {
if (!mapContainer || !Plotly || houseData.length === 0) return;
// Color scale based on price
const prices = houseData.map((d) => d.price);
const minP = Math.min(...prices);
const maxP = Math.max(...prices);
const trace = {
type: 'scattermapbox',
lat: houseData.map((d) => d.lat),
lon: houseData.map((d) => d.long),
mode: 'markers',
marker: {
size: houseData.map((d) => Math.max(5, Math.sqrt(d.sqft_living / 100))),
color: houseData.map((d) => d.price),
colorscale: 'Viridis',
cmin: minP,
cmax: maxP,
colorbar: {
title: 'Price ($)',
tickformat: ',.0f'
},
opacity: 0.7
},
text: houseData.map(
(d) =>
`Price: $${d.price.toLocaleString()}<br>` +
`Beds: ${d.bedrooms} | Baths: ${d.bathrooms}<br>` +
`Sqft: ${d.sqft_living.toLocaleString()}<br>` +
`Year Built: ${d.yr_built}<br>` +
`Grade: ${d.grade}`
),
hoverinfo: 'text'
};
const layout = {
mapbox: {
style: 'open-street-map',
center: {
lat: metadata?.location?.center?.[0] || 47.5,
lon: metadata?.location?.center?.[1] || -122.2
},
zoom: 9
},
margin: { l: 0, r: 0, t: 30, b: 0 },
title: {
text: `King County House Prices (${houseData.length} houses shown)`,
font: { size: 16 }
},
height: 500
};
Plotly.newPlot(mapContainer, [trace], layout, { responsive: true });
}
async function renderDistribution() {
if (!distContainer || !Plotly) return;
try {
const res = await fetch(`${API_BASE}/house/price-distribution?bins=30`);
const dist = await res.json();
const trace = {
x: dist.bin_centers,
y: dist.counts,
type: 'bar',
marker: {
color: dist.bin_centers,
colorscale: 'Viridis'
}
};
const layout = {
title: 'Price Distribution',
xaxis: {
title: 'Price ($)',
tickformat: ',.0f'
},
yaxis: { title: 'Count' },
margin: { l: 60, r: 30, t: 50, b: 60 },
height: 400
};
Plotly.newPlot(distContainer, [trace], layout, { responsive: true });
} catch (e) {
console.error('Failed to render distribution:', e);
}
}
async function renderCorrelation() {
if (!corrContainer || !Plotly) return;
try {
const res = await fetch(`${API_BASE}/house/correlation`);
const corr = await res.json();
const trace = {
z: corr.correlation,
x: corr.columns,
y: corr.columns,
type: 'heatmap',
colorscale: 'RdBu',
zmin: -1,
zmax: 1,
colorbar: { title: 'Correlation' }
};
const layout = {
title: 'Feature Correlation Matrix',
margin: { l: 100, r: 30, t: 50, b: 100 },
height: 500,
xaxis: { tickangle: 45 },
yaxis: { autorange: 'reversed' }
};
Plotly.newPlot(corrContainer, [trace], layout, { responsive: true });
} catch (e) {
console.error('Failed to render correlation:', e);
}
}
async function predictPrice() {
try {
const res = await fetch(`${API_BASE}/house/predict`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
bedrooms: predBedrooms,
bathrooms: predBathrooms,
sqft: predSqft,
age: predAge
})
});
prediction = await res.json();
} catch (e) {
error = 'Prediction failed';
}
}
function handleTabChange(tab: 'map' | 'distribution' | 'correlation') {
activeTab = tab;
setTimeout(() => {
if (tab === 'map') renderMap();
else if (tab === 'distribution') renderDistribution();
else if (tab === 'correlation') renderCorrelation();
}, 100);
}
function formatCurrency(value: number): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(value);
}
</script>
<svelte:head>
<title>House Price Predictor | Build with AI</title>
<meta name="description" content="Seattle/King County house price prediction and visualization tool with ML-powered estimates." />
</svelte:head>
<div class="min-h-screen bg-gradient-to-b from-slate-50 to-white">
<Navbar />
<main class="pt-24 pb-16 px-6">
<div class="max-w-7xl mx-auto">
<!-- Header -->
<div class="mb-6">
<a href="/tools" class="text-primary-600 hover:text-primary-700 text-sm mb-2 inline-block">&larr; Back to Tools</a>
<h1 class="text-2xl font-bold text-gray-900">House Price Predictor</h1>
<p class="text-gray-600 mt-1">
Seattle/King County house price prediction and visualization (21,613 houses, 2014-2015)
</p>
</div>
{#if loading}
<div class="flex items-center justify-center h-64">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
</div>
{:else if error}
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-4">
{error}
</div>
{:else}
<div class="grid grid-cols-1 lg:grid-cols-6 gap-4">
<!-- Left Panel: Filters & Selection -->
<div class="lg:col-span-1 space-y-4">
<!-- Filters Card -->
<div class="bg-white rounded-xl shadow-sm p-3 border border-gray-100">
<h2 class="text-sm font-semibold mb-3 flex items-center gap-1">
<span>Filters</span>
</h2>
<div class="space-y-3">
<div>
<span class="block text-xs font-medium text-gray-700 mb-1">Price Range</span>
<div class="flex gap-2">
<input
type="number"
bind:value={minPrice}
class="w-1/2 px-2 py-1 text-sm border rounded"
placeholder="Min"
/>
<input
type="number"
bind:value={maxPrice}
class="w-1/2 px-2 py-1 text-sm border rounded"
placeholder="Max"
/>
</div>
</div>
<div>
<span class="block text-xs font-medium text-gray-700 mb-1">Bedrooms</span>
<div class="flex gap-2">
<input
type="number"
bind:value={minBedrooms}
min="0"
max="10"
class="w-1/2 px-2 py-1 text-sm border rounded"
/>
<input
type="number"
bind:value={maxBedrooms}
min="0"
max="10"
class="w-1/2 px-2 py-1 text-sm border rounded"
/>
</div>
</div>
<div>
<span class="block text-xs font-medium text-gray-700 mb-1">Zipcode</span>
<select bind:value={selectedZipcode} class="w-full px-2 py-1 text-sm border rounded">
<option value="">All Zipcodes</option>
{#if metadata?.zipcodes}
{#each metadata.zipcodes as zip}
<option value={zip}>{zip}</option>
{/each}
{/if}
</select>
</div>
<label class="flex items-center gap-2">
<input type="checkbox" bind:checked={waterfrontOnly} class="rounded" />
<span class="text-sm text-gray-700">Waterfront only</span>
</label>
<button
onclick={updateCharts}
class="w-full bg-primary-600 text-white py-2 rounded-lg hover:bg-primary-700 transition-colors"
>
Apply Filters
</button>
</div>
</div>
<!-- Selection Card -->
<div class="bg-white rounded-xl shadow-sm p-3 border border-gray-100">
<h2 class="text-sm font-semibold mb-3 flex items-center gap-1">
<span>Selection</span>
</h2>
<div class="space-y-3">
<div>
<span class="block text-xs font-medium text-gray-700 mb-1">
Beds: {predBedrooms}
</span>
<input
type="range"
bind:value={predBedrooms}
min="1"
max="10"
class="w-full"
/>
</div>
<div>
<span class="block text-xs font-medium text-gray-700 mb-1">
Baths: {predBathrooms}
</span>
<input
type="range"
bind:value={predBathrooms}
min="1"
max="6"
step="0.5"
class="w-full"
/>
</div>
<div>
<span class="block text-xs font-medium text-gray-700 mb-1">
Sqft: {predSqft.toLocaleString()}
</span>
<input
type="range"
bind:value={predSqft}
min="500"
max="10000"
step="100"
class="w-full"
/>
</div>
<div>
<span class="block text-xs font-medium text-gray-700 mb-1">
Age: {predAge}yr
</span>
<input
type="range"
bind:value={predAge}
min="0"
max="120"
class="w-full"
/>
</div>
</div>
</div>
</div>
<!-- Main Chart Area -->
<div class="lg:col-span-4">
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<!-- Tabs -->
<div class="flex border-b border-gray-200">
<button
onclick={() => handleTabChange('map')}
class="flex-1 px-4 py-3 text-sm font-medium transition-colors
{activeTab === 'map'
? 'bg-primary-50 text-primary-700 border-b-2 border-primary-600'
: 'text-gray-600 hover:bg-gray-50'}"
>
Map View
</button>
<button
onclick={() => handleTabChange('distribution')}
class="flex-1 px-4 py-3 text-sm font-medium transition-colors
{activeTab === 'distribution'
? 'bg-primary-50 text-primary-700 border-b-2 border-primary-600'
: 'text-gray-600 hover:bg-gray-50'}"
>
Distribution
</button>
<button
onclick={() => handleTabChange('correlation')}
class="flex-1 px-4 py-3 text-sm font-medium transition-colors
{activeTab === 'correlation'
? 'bg-primary-50 text-primary-700 border-b-2 border-primary-600'
: 'text-gray-600 hover:bg-gray-50'}"
>
Correlations
</button>
</div>
<!-- Chart Containers -->
<div class="p-4">
<div bind:this={mapContainer} class:hidden={activeTab !== 'map'}></div>
<div bind:this={distContainer} class:hidden={activeTab !== 'distribution'}></div>
<div bind:this={corrContainer} class:hidden={activeTab !== 'correlation'}></div>
</div>
<!-- Predict Price Bar -->
<div class="px-4 py-3 bg-gray-50 border-t border-gray-200 flex items-center justify-between gap-4">
<div class="flex items-center gap-4 text-sm text-gray-600">
<span>{predBedrooms} bed</span>
<span>{predBathrooms} bath</span>
<span>{predSqft.toLocaleString()} sqft</span>
<span>{predAge}yr old</span>
</div>
<div class="flex items-center gap-3">
<button
onclick={predictPrice}
class="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium"
>
Predict Price
</button>
{#if prediction}
<div class="px-4 py-2 bg-green-100 rounded-lg border border-green-300">
<span class="text-xl font-bold text-green-700">{prediction.formatted_price}</span>
</div>
{/if}
</div>
</div>
</div>
</div>
<!-- Right Panel: Statistics -->
<div class="lg:col-span-1">
<div class="bg-white rounded-xl shadow-sm p-3 border border-gray-100">
<h2 class="text-sm font-semibold mb-3 flex items-center gap-1">
<span>Stats</span>
</h2>
{#if statistics}
<div class="space-y-2">
<div class="p-2 bg-gray-50 rounded-lg">
<p class="text-xs text-gray-500">Total</p>
<p class="text-base font-bold text-gray-900">
{statistics.count?.toLocaleString()}
</p>
</div>
<div class="p-2 bg-blue-50 rounded-lg">
<p class="text-xs text-blue-600">Avg</p>
<p class="text-base font-bold text-blue-700">
{formatCurrency(statistics.mean)}
</p>
</div>
<div class="p-2 bg-green-50 rounded-lg">
<p class="text-xs text-green-600">Median</p>
<p class="text-base font-bold text-green-700">
{formatCurrency(statistics.median)}
</p>
</div>
<div class="grid grid-cols-2 gap-1">
<div class="p-1 bg-gray-50 rounded text-center">
<p class="text-xs text-gray-500">Min</p>
<p class="text-xs font-semibold">{formatCurrency(statistics.min)}</p>
</div>
<div class="p-1 bg-gray-50 rounded text-center">
<p class="text-xs text-gray-500">Max</p>
<p class="text-xs font-semibold">{formatCurrency(statistics.max)}</p>
</div>
</div>
{#if statistics.percentiles}
<div class="mt-2 pt-2 border-t border-gray-100">
<p class="text-xs font-medium text-gray-600 mb-1">Percentiles</p>
<div class="space-y-0.5 text-xs">
<div class="flex justify-between">
<span class="text-gray-500">25th</span>
<span class="font-medium">{formatCurrency(statistics.percentiles['25'])}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">75th</span>
<span class="font-medium">{formatCurrency(statistics.percentiles['75'])}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">90th</span>
<span class="font-medium">{formatCurrency(statistics.percentiles['90'])}</span>
</div>
</div>
</div>
{/if}
</div>
{/if}
{#if metadata}
<div class="mt-3 pt-2 border-t border-gray-100">
<p class="text-xs text-gray-500">
{metadata.region}<br/>
{metadata.data_period}
</p>
</div>
{/if}
</div>
</div>
</div>
{/if}
</div>
</main>
<Footer />
</div>

View file

@ -0,0 +1,915 @@
<script lang="ts">
import Navbar from '$lib/components/Navbar.svelte';
import Footer from '$lib/components/Footer.svelte';
// API Base - uses env var for local dev, defaults to cockpit for production
const API_BASE = (import.meta.env.VITE_API_URL || 'https://cockpit.valuecurve.co') + '/api/v1';
// ============================================
// TRUE CLIENT-SIDE PII DETECTION (Browser-Only)
// ============================================
// These regex patterns are ported from backend - text never leaves browser
// Base confidence values aligned with backend after context adjustment
// Backend adjusts: +0.10 per context keyword, -0.30 for test data, range 0.3-0.99
const PII_PATTERNS: Record<string, { pattern: RegExp; description: string; category: string; confidence: number }> = {
EMAIL: {
pattern: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/gi,
description: "Email addresses",
category: "pii",
confidence: 0.75 // Backend base ~0.65-0.85 after context
},
EMAIL_OBFUSCATED: {
pattern: /[A-Za-z0-9](?:[-\s]*[A-Za-z0-9]){2,}\s*(?:\[at\]|\(at\))\s*[A-Za-z0-9](?:[-\s]*[A-Za-z0-9]){2,}\s*(?:\[dot\]|\(dot\)|\s+dot\s+)\s*[A-Za-z]{2,}/gi,
description: "Obfuscated email addresses",
category: "pii",
confidence: 0.85
},
PHONE_US: {
pattern: /\b(?:\+?1[-.\s]?)?\(?[0-9]{3}\)?[-.\s]?[0-9]{3}[-.\s]?[0-9]{4}\b/g,
description: "US Phone numbers",
category: "pii",
confidence: 0.65 // Backend returns ~0.65 for phones
},
PHONE_INTL: {
pattern: /\+(?:49|44|33|39|34|31|32|43|41|48|351|353|358|47|46|45|420|36|40|359|385|386|421|370|371|372|352|356|357|30|55|52|54|56|57|51|81|82|86|91|61|64|65|852)\s?[0-9]{1,4}[\s-]?[0-9]{3,4}[\s-]?[0-9]{3,6}\b/g,
description: "International Phone numbers",
category: "pii",
confidence: 0.65
},
SSN: {
pattern: /\b\d{3}[-.\s_]\d{2}[-.\s_]\d{4}\b/g,
description: "Social Security Numbers",
category: "pii",
confidence: 0.75 // Backend returns ~0.75 for SSN
},
CREDIT_CARD: {
pattern: /\b(?:4[0-9]{3}[-\s]?[0-9]{4}[-\s]?[0-9]{4}[-\s]?[0-9]{4}|5[1-5][0-9]{2}[-\s]?[0-9]{4}[-\s]?[0-9]{4}[-\s]?[0-9]{4}|3[47][0-9]{2}[-\s]?[0-9]{6}[-\s]?[0-9]{5}|6(?:011|5[0-9]{2})[-\s]?[0-9]{4}[-\s]?[0-9]{4}[-\s]?[0-9]{4})\b/g,
description: "Credit card numbers",
category: "financial",
confidence: 0.99 // Backend boosts valid Luhn to 0.99
},
IP_ADDRESS: {
pattern: /\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b/g,
description: "IP addresses",
category: "pii",
confidence: 0.65 // Backend returns ~0.65 for IPs
},
DATE_OF_BIRTH: {
pattern: /\b(?:(?:0?[1-9]|1[0-2])[/-](?:0?[1-9]|[12][0-9]|3[01])[/-](?:19|20)\d{2}|(?:19|20)\d{2}[-/](?:0?[1-9]|1[0-2])[-/](?:0?[1-9]|[12][0-9]|3[01]))\b/g,
description: "Dates",
category: "pii",
confidence: 0.65
},
US_ADDRESS: {
pattern: /\b\d{1,5}\s+(?:[A-Za-z]+\s+){1,4}(?:Street|St|Avenue|Ave|Road|Rd|Boulevard|Blvd|Drive|Dr|Lane|Ln|Court|Ct|Way|Place|Pl)\.?/gi,
description: "US addresses",
category: "pii",
confidence: 0.75
},
IBAN: {
pattern: /\b(?:DE|GB|FR|ES|IT|NL|BE|AT|CH|PL|PT|IE|FI|NO|SE|DK)\d{2}[\s]?[A-Z0-9]{4}[\s]?[A-Z0-9]{4}[\s]?[A-Z0-9]{4}[\s]?[A-Z0-9]{0,18}\b/g,
description: "IBAN",
category: "financial",
confidence: 0.90
},
INDIA_AADHAAR: {
pattern: /\b[2-9]\d{3}[\s-]?\d{4}[\s-]?\d{4}\b/g,
description: "India Aadhaar numbers",
category: "pii",
confidence: 0.75
},
AWS_ACCESS_KEY: {
pattern: /\b(?:AKIA|ABIA|ACCA|ASIA)[A-Z0-9]{16}\b/g,
description: "AWS Access Key IDs",
category: "secret",
confidence: 0.99
},
GITHUB_TOKEN: {
pattern: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}\b/g,
description: "GitHub Tokens",
category: "secret",
confidence: 0.99
},
SLACK_TOKEN: {
pattern: /\bxox[baprs]-[0-9]{10,13}-[0-9]{10,13}[a-zA-Z0-9-]*\b/g,
description: "Slack Tokens",
category: "secret",
confidence: 0.99
},
STRIPE_KEY: {
pattern: /\b(?:sk|pk)_(?:test|live)_[A-Za-z0-9]{8,}\b/g,
description: "Stripe API Keys",
category: "secret",
confidence: 0.99
},
GOOGLE_API_KEY: {
pattern: /\bAIza[A-Za-z0-9_-]{35}\b/g,
description: "Google API Keys",
category: "secret",
confidence: 0.99
},
JWT_TOKEN: {
pattern: /\beyJ[A-Za-z0-9_-]*\.eyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]*\b/g,
description: "JWT Tokens",
category: "secret",
confidence: 0.90
},
PRIVATE_KEY: {
pattern: /-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----/g,
description: "Private key headers",
category: "secret",
confidence: 0.99
},
PASSWORD_IN_URL: {
pattern: /(?:password|passwd|pwd|pass)["']?\s*(?:[:=])\s*["']?([^\s"'&,]{6,})["']?/gi,
description: "Passwords in plaintext",
category: "secret",
confidence: 0.85
}
};
interface DetectedEntity {
type: string;
start: number;
end: number;
value: string;
masked_value: string;
confidence: number;
length: number;
}
function detectPIIClientSide(text: string, options: {
detectEmails: boolean;
detectPhones: boolean;
detectSSN: boolean;
detectCreditCards: boolean;
detectIPAddresses: boolean;
detectDates: boolean;
detectAddresses: boolean;
detectIBAN: boolean;
detectSecrets: boolean;
}): DetectedEntity[] {
const entities: DetectedEntity[] = [];
// Map options to pattern types
const enabledPatterns: string[] = [];
if (options.detectEmails) enabledPatterns.push('EMAIL', 'EMAIL_OBFUSCATED');
if (options.detectPhones) enabledPatterns.push('PHONE_US', 'PHONE_INTL');
if (options.detectSSN) enabledPatterns.push('SSN', 'INDIA_AADHAAR');
if (options.detectCreditCards) enabledPatterns.push('CREDIT_CARD');
if (options.detectIPAddresses) enabledPatterns.push('IP_ADDRESS');
if (options.detectDates) enabledPatterns.push('DATE_OF_BIRTH');
if (options.detectAddresses) enabledPatterns.push('US_ADDRESS');
if (options.detectIBAN) enabledPatterns.push('IBAN');
if (options.detectSecrets) {
enabledPatterns.push('AWS_ACCESS_KEY', 'GITHUB_TOKEN', 'SLACK_TOKEN',
'STRIPE_KEY', 'GOOGLE_API_KEY', 'JWT_TOKEN', 'PRIVATE_KEY', 'PASSWORD_IN_URL');
}
for (const patternName of enabledPatterns) {
const patternDef = PII_PATTERNS[patternName];
if (!patternDef) continue;
// Create new regex to reset lastIndex
const regex = new RegExp(patternDef.pattern.source, patternDef.pattern.flags);
let match;
while ((match = regex.exec(text)) !== null) {
const value = match[0];
const maxLen = 35;
entities.push({
type: patternName,
start: match.index,
end: match.index + value.length,
value: value.length > maxLen ? value.slice(0, maxLen) + '...' : value,
masked_value: ('*'.repeat(value.length)).slice(0, maxLen) + (value.length > maxLen ? '...' : ''),
confidence: patternDef.confidence,
length: value.length
});
}
}
// Sort by confidence (highest first), then by length (longest first) for overlap resolution
entities.sort((a, b) => {
if (b.confidence !== a.confidence) return b.confidence - a.confidence;
return b.length - a.length;
});
// Remove overlaps - keep highest confidence/longest matches
const filtered: DetectedEntity[] = [];
for (const entity of entities) {
const overlaps = filtered.some(e =>
(entity.start >= e.start && entity.start < e.end) ||
(entity.end > e.start && entity.end <= e.end) ||
(entity.start <= e.start && entity.end >= e.end)
);
if (!overlaps) {
filtered.push(entity);
}
}
// Re-sort by position for display
filtered.sort((a, b) => a.start - b.start);
return filtered;
}
// ============================================
let inputMode: 'text' | 'file' = $state('text');
let textInput = $state('');
let file: File | null = $state(null);
let fileName = $state('');
let loading = $state(false);
let error = $state('');
let result: any = $state(null);
let activeTab: 'overview' | 'entities' | 'redacted' = $state('overview');
// Detection options
let detectEmails = $state(true);
let detectPhones = $state(true);
let detectSSN = $state(true);
let detectCreditCards = $state(true);
let detectIPAddresses = $state(true);
let detectDates = $state(true);
let detectAddresses = $state(true);
let detectIBAN = $state(true);
let detectSecrets = $state(true);
// Security option: Client-side redaction mode
let coordinatesOnly = $state(false);
function clientSideRedact(text: string, entities: Array<{start: number, end: number, type: string, length: number}>): string {
if (!entities || entities.length === 0) return text;
const sorted = [...entities].sort((a, b) => b.start - a.start);
let resultText = text;
for (const entity of sorted) {
const masked = '*'.repeat(entity.length);
resultText = resultText.slice(0, entity.start) + masked + resultText.slice(entity.end);
}
return resultText;
}
function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
if (input.files && input.files[0]) {
file = input.files[0];
fileName = file.name;
result = null;
error = '';
}
}
function handleDrop(event: DragEvent) {
event.preventDefault();
if (event.dataTransfer?.files && event.dataTransfer.files[0]) {
file = event.dataTransfer.files[0];
fileName = file.name;
result = null;
error = '';
}
}
function handleDragOver(event: DragEvent) {
event.preventDefault();
}
async function scanForPII() {
if (inputMode === 'text' && !textInput.trim()) {
error = 'Please enter some text to scan';
return;
}
if (inputMode === 'file' && !file) {
error = 'Please select a file first';
return;
}
loading = true;
error = '';
result = null;
try {
// ============================================
// TRUE CLIENT-SIDE MODE: No network request!
// ============================================
if (coordinatesOnly && inputMode === 'text') {
console.log('%c[Privacy Scanner] Browser-Only Mode: All detection running locally. ZERO network requests.', 'color: #22c55e; font-weight: bold;');
const normalizedText = textInput.replace(/\r\n/g, '\n');
const entities = detectPIIClientSide(normalizedText, {
detectEmails, detectPhones, detectSSN, detectCreditCards,
detectIPAddresses, detectDates, detectAddresses, detectIBAN, detectSecrets
});
// Build result object matching backend format
const entitiesByType: Record<string, number> = {};
for (const e of entities) {
entitiesByType[e.type] = (entitiesByType[e.type] || 0) + 1;
}
// Calculate risk score (matches backend formula)
const riskWeights: Record<string, number> = {
SSN: 100, CREDIT_CARD: 95, PRIVATE_KEY: 100, INDIA_AADHAAR: 90,
AWS_ACCESS_KEY: 100, GITHUB_TOKEN: 95,
STRIPE_KEY: 95, PASSWORD_IN_URL: 90, JWT_TOKEN: 85,
IBAN: 85, EMAIL: 40, EMAIL_OBFUSCATED: 40, PHONE_US: 35, PHONE_INTL: 35,
IP_ADDRESS: 30, DATE_OF_BIRTH: 50, US_ADDRESS: 55,
SLACK_TOKEN: 90, GOOGLE_API_KEY: 85
};
let totalScore = 0;
for (const e of entities) {
const weight = riskWeights[e.type] || 25;
totalScore += weight * e.confidence;
}
// Backend formula: (average score) + (entity count * 5)
const riskScore = Math.min(100, Math.floor(totalScore / Math.max(1, entities.length) + entities.length * 5));
// Backend thresholds: 70=CRITICAL, 50=HIGH, 30=MEDIUM
const riskLevel = riskScore >= 70 ? 'CRITICAL'
: riskScore >= 50 ? 'HIGH'
: riskScore >= 30 ? 'MEDIUM'
: 'LOW';
result = {
total_entities: entities.length,
entities_by_type: entitiesByType,
entities: entities,
risk_score: riskScore,
risk_level: riskLevel,
redacted_preview: clientSideRedact(normalizedText, entities),
coordinates_only: true,
client_side_only: true // Flag to indicate true client-side
};
} else {
// ============================================
// BACKEND MODE: Send to server
// ============================================
let response;
if (inputMode === 'text') {
const normalizedText = textInput.replace(/\r\n/g, '\n');
const formData = new FormData();
formData.append('text', normalizedText);
formData.append('detect_emails', String(detectEmails));
formData.append('detect_phones', String(detectPhones));
formData.append('detect_ssn', String(detectSSN));
formData.append('detect_credit_cards', String(detectCreditCards));
formData.append('detect_ip_addresses', String(detectIPAddresses));
formData.append('detect_dates', String(detectDates));
formData.append('detect_addresses', String(detectAddresses));
formData.append('detect_iban', String(detectIBAN));
formData.append('detect_secrets', String(detectSecrets));
formData.append('coordinates_only', 'false');
response = await fetch(`${API_BASE}/privacy/scan-text`, {
method: 'POST',
body: formData
});
} else {
const formData = new FormData();
formData.append('file', file!);
response = await fetch(`${API_BASE}/privacy/scan-file`, {
method: 'POST',
body: formData
});
}
if (!response.ok) {
const errData = await response.json();
throw new Error(errData.detail || 'Scan failed');
}
result = await response.json();
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to scan for PII';
} finally {
loading = false;
}
}
function getRiskColor(level: string): string {
switch (level) {
case 'CRITICAL':
return 'text-red-700 bg-red-50 border-red-200';
case 'HIGH':
return 'text-orange-700 bg-orange-50 border-orange-200';
case 'MEDIUM':
return 'text-yellow-700 bg-yellow-50 border-yellow-200';
default:
return 'text-green-700 bg-green-50 border-green-200';
}
}
function getPIITypeIcon(type: string): string {
switch (type) {
case 'EMAIL': return '@';
case 'PHONE_US':
case 'PHONE_INTL': return '#';
case 'SSN': return 'SSN';
case 'CREDIT_CARD': return '$';
case 'IP_ADDRESS': return 'IP';
case 'DATE_OF_BIRTH': return 'DOB';
case 'US_ADDRESS':
case 'UK_ADDRESS':
case 'EU_ADDRESS': return 'ADR';
case 'IBAN':
case 'BANK_ACCOUNT': return 'BNK';
case 'AWS_ACCESS_KEY':
case 'AWS_SECRET_KEY': return 'AWS';
case 'GITHUB_TOKEN': return 'GH';
case 'STRIPE_KEY': return 'STR';
case 'GOOGLE_API_KEY': return 'GCP';
case 'PASSWORD_IN_URL': return 'PWD';
case 'PRIVATE_KEY': return 'KEY';
case 'JWT_TOKEN': return 'JWT';
case 'SLACK_TOKEN': return 'SLK';
case 'GENERIC_API_KEY': return 'API';
default: return '?';
}
}
function getPIITypeColor(type: string): string {
switch (type) {
case 'SSN':
case 'CREDIT_CARD':
case 'IBAN':
case 'BANK_ACCOUNT':
return 'bg-red-100 text-red-700 border-red-200';
case 'AWS_ACCESS_KEY':
case 'AWS_SECRET_KEY':
case 'PRIVATE_KEY':
case 'PASSWORD_IN_URL':
case 'GITHUB_TOKEN':
case 'STRIPE_KEY':
return 'bg-purple-100 text-purple-700 border-purple-200';
case 'GOOGLE_API_KEY':
case 'SLACK_TOKEN':
case 'JWT_TOKEN':
case 'GENERIC_API_KEY':
return 'bg-orange-100 text-orange-700 border-orange-200';
case 'EMAIL':
case 'PHONE_US':
case 'PHONE_INTL':
return 'bg-yellow-100 text-yellow-700 border-yellow-200';
case 'US_ADDRESS':
case 'UK_ADDRESS':
case 'EU_ADDRESS':
return 'bg-amber-100 text-amber-700 border-amber-200';
default:
return 'bg-blue-100 text-blue-700 border-blue-200';
}
}
let copySuccess = $state(false);
async function copyToClipboard() {
if (!result || !result.entities || result.entities.length === 0) return;
const header = 'Type\tOriginal Value\tMasked Value\tConfidence';
const rows = result.entities.map((e: any) =>
`${e.type}\t${e.value}\t${e.masked_value}\t${Math.round(e.confidence * 100)}%`
);
const text = [header, ...rows].join('\n');
try {
await navigator.clipboard.writeText(text);
copySuccess = true;
setTimeout(() => copySuccess = false, 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
}
let copyRedactedSuccess = $state(false);
async function copyRedactedToClipboard() {
if (!result || !result.redacted_preview) return;
try {
await navigator.clipboard.writeText(result.redacted_preview);
copyRedactedSuccess = true;
setTimeout(() => copyRedactedSuccess = false, 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
}
function loadSampleText() {
textInput = `Customer Record:
Name: John Smith
Email: john.smith@example.com
US Phone: (555) 123-4567
SSN: 123-45-6789
Credit Card: 4532 0151 1283 0366
IP Address: 192.168.1.100
--- CLOUD SECRETS ---
AWS Key: AKIAIOSFODNN7EXAMPLE
GitHub Token: ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Please contact support@company.org for assistance.`;
}
function clearAll() {
textInput = '';
file = null;
fileName = '';
result = null;
error = '';
}
</script>
<svelte:head>
<title>Privacy Scanner | Build with AI</title>
<meta name="description" content="Detect and redact personally identifiable information (PII) from text and files." />
</svelte:head>
<div class="min-h-screen bg-gradient-to-b from-slate-50 to-white">
<Navbar />
<main class="pt-20 pb-10 px-5">
<div class="max-w-6xl mx-auto">
<!-- Header -->
<div class="mb-5 flex items-center justify-between">
<div class="flex items-center gap-4">
<a href="/tools/" class="text-sm text-primary-600 hover:text-primary-700">&larr; Tools</a>
<h1 class="text-2xl font-bold text-gray-900">Privacy Scanner</h1>
<span class="text-gray-400 text-sm hidden sm:inline">|</span>
<p class="text-gray-500 text-sm hidden sm:inline">Detect & redact PII from text and files</p>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-5">
<!-- Input Section -->
<div class="bg-white rounded-xl border border-gray-100 shadow-sm p-5">
<h2 class="text-base font-bold text-gray-900 mb-3">Input</h2>
<!-- Mode Toggle -->
<div class="flex gap-1 mb-3 p-1 bg-gray-100 rounded-lg">
<button
class="flex-1 py-2 px-3 text-sm font-medium rounded-md transition-all {inputMode === 'text'
? 'bg-white text-primary-600 shadow-sm'
: 'text-gray-500 hover:text-gray-700'}"
onclick={() => (inputMode = 'text')}
>
Text
</button>
<button
class="flex-1 py-2 px-3 text-sm font-medium rounded-md transition-all {inputMode === 'file'
? 'bg-white text-primary-600 shadow-sm'
: 'text-gray-500 hover:text-gray-700'}"
onclick={() => (inputMode = 'file')}
>
File
</button>
</div>
{#if inputMode === 'text'}
<div class="space-y-3">
<div class="flex justify-between items-center">
<label for="text-input" class="text-sm font-medium text-gray-700">Text to Scan</label>
<div class="flex gap-2">
<button
class="text-xs text-primary-600 hover:text-primary-700"
onclick={loadSampleText}
>
Load Sample
</button>
{#if textInput || result}
<span class="text-gray-300">|</span>
<button
class="text-xs text-gray-500 hover:text-red-600"
onclick={clearAll}
>
Clear
</button>
{/if}
</div>
</div>
<textarea
id="text-input"
bind:value={textInput}
placeholder="Paste text containing potential PII here..."
class="w-full px-3 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 min-h-[150px] resize-none font-mono text-xs"
></textarea>
</div>
{:else}
<!-- Drop Zone -->
<div
class="border-2 border-dashed border-gray-300 rounded-xl p-6 text-center hover:border-primary-400 transition-colors cursor-pointer"
ondrop={handleDrop}
ondragover={handleDragOver}
role="button"
tabindex="0"
>
<input
type="file"
accept=".csv,.txt,.json"
onchange={handleFileSelect}
class="hidden"
id="file-input"
/>
<label for="file-input" class="cursor-pointer">
<div class="w-12 h-12 rounded-xl bg-primary-100 flex items-center justify-center mx-auto mb-3">
<svg class="w-6 h-6 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<p class="text-sm text-gray-600">
<span class="text-primary-600 font-medium">Click to upload</span> or drag and drop
</p>
<p class="text-xs text-gray-400 mt-1">CSV, TXT, or JSON files</p>
</label>
</div>
{#if fileName}
<div class="mt-3 flex items-center gap-2 p-2 bg-gray-50 rounded-lg">
<svg class="w-4 h-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-sm text-gray-700 truncate flex-1">{fileName}</span>
</div>
{/if}
{/if}
<!-- Detection Options -->
<div class="mt-4 pt-3 border-t border-gray-200">
<div class="flex items-center justify-between mb-2">
<h3 class="text-xs font-semibold text-gray-500 uppercase">PII Detection</h3>
<label class="flex items-center gap-1.5 cursor-pointer text-xs">
<input type="checkbox" bind:checked={detectSecrets} class="rounded text-primary-600" />
<span class="text-gray-600">Secrets</span>
</label>
</div>
<div class="grid grid-cols-4 gap-1.5 text-xs">
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" bind:checked={detectEmails} class="rounded text-primary-600" />
<span class="text-gray-700">Email</span>
</label>
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" bind:checked={detectPhones} class="rounded text-primary-600" />
<span class="text-gray-700">Phone</span>
</label>
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" bind:checked={detectSSN} class="rounded text-primary-600" />
<span class="text-gray-700">SSN</span>
</label>
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" bind:checked={detectCreditCards} class="rounded text-primary-600" />
<span class="text-gray-700">Cards</span>
</label>
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" bind:checked={detectIPAddresses} class="rounded text-primary-600" />
<span class="text-gray-700">IP</span>
</label>
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" bind:checked={detectDates} class="rounded text-primary-600" />
<span class="text-gray-700">Dates</span>
</label>
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" bind:checked={detectAddresses} class="rounded text-primary-600" />
<span class="text-gray-700">Addr</span>
</label>
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" bind:checked={detectIBAN} class="rounded text-primary-600" />
<span class="text-gray-700">IBAN</span>
</label>
</div>
<!-- Security Mode - Inline -->
<label class="flex items-center gap-2 cursor-pointer text-xs mt-2.5 pt-2.5 border-t border-gray-100" title="All detection runs in your browser - no data sent to server">
<svg class="w-3.5 h-3.5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
<input type="checkbox" bind:checked={coordinatesOnly} class="rounded text-green-600" disabled={inputMode === 'file'} />
<span class="text-gray-600">Browser-Only Mode <span class="text-green-600 font-medium">(Zero Network)</span></span>
</label>
</div>
<button
onclick={scanForPII}
disabled={loading || (inputMode === 'text' ? !textInput.trim() : !file)}
class="w-full mt-4 px-5 py-2.5 bg-primary-600 text-white rounded-lg text-sm font-medium hover:bg-primary-700 transition-all shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Scanning...' : 'Scan for PII'}
</button>
{#if error}
<div class="mt-3 p-3 bg-red-50 text-red-700 text-sm rounded-lg border border-red-100">
{error}
</div>
{/if}
</div>
<!-- Results Section -->
<div class="lg:col-span-2 bg-white rounded-xl border border-gray-100 shadow-sm p-5">
<h2 class="text-base font-bold text-gray-900 mb-4">Scan Results</h2>
{#if result}
<!-- Risk Summary -->
<div class="grid grid-cols-4 gap-3 mb-5">
<div class="bg-gray-50 rounded-lg p-2.5 text-center border border-gray-100">
<p class="text-xs text-gray-500">PII Found</p>
<p class="text-xl font-bold {result.total_entities > 0 ? 'text-red-600' : 'text-green-600'}">
{result.total_entities}
</p>
</div>
<div class="bg-gray-50 rounded-lg p-2.5 text-center border border-gray-100">
<p class="text-xs text-gray-500">Types</p>
<p class="text-xl font-bold text-gray-800">
{Object.keys(result.entities_by_type || {}).length}
</p>
</div>
<div class="bg-gray-50 rounded-lg p-2.5 text-center border border-gray-100">
<p class="text-xs text-gray-500">Risk Score</p>
<p class="text-xl font-bold text-gray-800">{result.risk_score}</p>
</div>
<div class="rounded-lg p-2.5 text-center border {getRiskColor(result.risk_level)}">
<p class="text-xs opacity-80">Risk Level</p>
<p class="text-xl font-bold">{result.risk_level}</p>
</div>
</div>
<!-- Tabs -->
<div class="flex gap-1 mb-4 border-b border-gray-200">
<button
class="px-4 py-2 text-sm font-medium transition-colors {activeTab === 'overview'
? 'text-primary-600 border-b-2 border-primary-600'
: 'text-gray-500 hover:text-gray-700'}"
onclick={() => (activeTab = 'overview')}
>
Overview
</button>
<button
class="px-4 py-2 text-sm font-medium transition-colors {activeTab === 'entities'
? 'text-primary-600 border-b-2 border-primary-600'
: 'text-gray-500 hover:text-gray-700'}"
onclick={() => (activeTab = 'entities')}
>
Entities ({result.total_entities})
</button>
<button
class="px-4 py-2 text-sm font-medium transition-colors {activeTab === 'redacted'
? 'text-primary-600 border-b-2 border-primary-600'
: 'text-gray-500 hover:text-gray-700'}"
onclick={() => (activeTab = 'redacted')}
>
Redacted Preview
</button>
</div>
<!-- Tab Content -->
{#if activeTab === 'overview'}
<div class="space-y-4">
{#if Object.keys(result.entities_by_type || {}).length > 0}
<div class="bg-gray-50 rounded-lg p-4 border border-gray-100">
<h3 class="text-sm font-semibold text-gray-700 mb-3">PII by Type</h3>
<div class="grid grid-cols-2 gap-2">
{#each Object.entries(result.entities_by_type) as [type, count]}
<div class="flex items-center justify-between p-2 rounded-lg border {getPIITypeColor(type)}">
<div class="flex items-center gap-2">
<span class="text-xs font-bold w-6 h-6 rounded flex items-center justify-center bg-white/50">
{getPIITypeIcon(type)}
</span>
<span class="text-sm font-medium">{type}</span>
</div>
<span class="text-sm font-bold">{count}</span>
</div>
{/each}
</div>
</div>
{:else}
<div class="bg-green-50 rounded-lg p-4 border border-green-200">
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-sm text-green-700 font-medium">No PII detected in the input</span>
</div>
</div>
{/if}
<div class="rounded-lg p-4 border {getRiskColor(result.risk_level)}">
<h3 class="text-sm font-semibold mb-2">Risk Assessment</h3>
<p class="text-sm opacity-90">
{#if result.risk_level === 'CRITICAL'}
Critical risk! Highly sensitive PII (SSN, Credit Cards) detected. Immediate action required.
{:else if result.risk_level === 'HIGH'}
High risk! Multiple sensitive PII elements found. Consider redaction before sharing.
{:else if result.risk_level === 'MEDIUM'}
Medium risk. Some PII detected that may require attention.
{:else}
Low risk. Minimal or no PII detected.
{/if}
</p>
</div>
</div>
{:else if activeTab === 'entities'}
<div class="overflow-x-auto">
{#if result.entities && result.entities.length > 0}
<div class="flex justify-end mb-3">
<button
onclick={copyToClipboard}
class="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg transition-all
{copySuccess
? 'bg-green-100 text-green-700 border border-green-300'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 border border-gray-200'}"
>
{#if copySuccess}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
Copied!
{:else}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
Copy
{/if}
</button>
</div>
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200">
<th class="text-left py-2 px-3 font-medium text-gray-700">Type</th>
<th class="text-left py-2 px-3 font-medium text-gray-700">Original Value</th>
<th class="text-left py-2 px-3 font-medium text-gray-700">Masked Value</th>
<th class="text-right py-2 px-3 font-medium text-gray-700">Confidence</th>
</tr>
</thead>
<tbody>
{#each result.entities as entity}
<tr class="border-b border-gray-100">
<td class="py-2 px-3">
<span class="text-xs px-2 py-1 rounded border {getPIITypeColor(entity.type)}">
{entity.type}
</span>
</td>
<td class="py-2 px-3 font-mono text-xs text-red-600">{entity.value}</td>
<td class="py-2 px-3 font-mono text-xs text-green-600">{entity.masked_value}</td>
<td class="py-2 px-3 text-right">
<span class="text-xs px-2 py-0.5 rounded {entity.confidence >= 0.9
? 'bg-green-100 text-green-700'
: entity.confidence >= 0.7
? 'bg-yellow-100 text-yellow-700'
: 'bg-red-100 text-red-700'}">
{Math.round(entity.confidence * 100)}%
</span>
</td>
</tr>
{/each}
</tbody>
</table>
{:else}
<div class="text-center py-8 text-gray-500">
No PII entities found
</div>
{/if}
</div>
{:else if activeTab === 'redacted'}
<div class="space-y-3">
<div class="bg-gray-50 rounded-lg p-4 border border-gray-200">
<div class="flex justify-between items-center mb-2">
<h3 class="text-sm font-semibold text-gray-700">Redacted Text Preview</h3>
<button
onclick={copyRedactedToClipboard}
class="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg transition-all
{copyRedactedSuccess
? 'bg-green-100 text-green-700 border border-green-300'
: 'bg-white text-gray-600 hover:bg-gray-100 border border-gray-200'}"
>
{#if copyRedactedSuccess}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
Copied!
{:else}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
Copy
{/if}
</button>
</div>
<pre class="text-xs font-mono whitespace-pre-wrap text-gray-600 bg-white p-3 rounded-lg border border-gray-100 max-h-[300px] overflow-auto">{result.redacted_preview || 'No preview available'}</pre>
</div>
<p class="text-xs text-gray-500">
This preview shows PII values masked for safe sharing.
</p>
</div>
{/if}
{:else}
<div class="text-center py-12">
<div class="w-16 h-16 rounded-2xl bg-gray-100 flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<p class="text-gray-500">Enter text or upload a file to scan for PII</p>
</div>
{/if}
</div>
</div>
</div>
</main>
<Footer />
</div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 476 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

View file

@ -0,0 +1,35 @@
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: undefined,
precompress: false,
strict: true
}),
prerender: {
handleHttpError: ({ path, message }) => {
// Ignore external links (they'll be served by Caddy separately)
// These are deployed as separate apps
if (
path.startsWith('/guides/statistical-tests') ||
path.startsWith('/tools/flowchart') ||
path.startsWith('/statistical-tests') ||
path.startsWith('/flowchart') ||
path.startsWith('/data-visualization') ||
path.startsWith('/interactive-tools')
) {
return;
}
throw new Error(message);
}
}
}
};
export default config;

View file

@ -0,0 +1,26 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{html,js,svelte,ts}'],
theme: {
extend: {
fontFamily: {
sans: ['Inter', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif']
},
colors: {
primary: {
50: '#e3f2fd',
100: '#bbdefb',
200: '#90caf9',
300: '#64b5f6',
400: '#42a5f5',
500: '#2196f3',
600: '#1e88e5',
700: '#1976d2',
800: '#1565c0',
900: '#0d47a1'
}
}
}
},
plugins: []
};

View file

@ -0,0 +1,14 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}

View file

@ -0,0 +1,60 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
import { readFileSync, existsSync } from 'fs';
import { resolve, join } from 'path';
import { fileURLToPath } from 'url';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
// Custom Vite plugin to serve pre-built static apps in dev mode
function serveStaticApps() {
const staticRoutes = {
'/guides/statistical-tests': resolve(__dirname, '../../guides/statistical-tests'),
'/tools/flowchart': resolve(__dirname, '../../tools/flowchart')
};
return {
name: 'serve-static-apps',
configureServer(server) {
server.middlewares.use((req, res, next) => {
const url = req.url?.split('?')[0] || '';
for (const [route, basePath] of Object.entries(staticRoutes)) {
if (url.startsWith(route)) {
let filePath = url.slice(route.length) || '/index.html';
if (filePath === '/') filePath = '/index.html';
const fullPath = join(basePath, filePath);
if (existsSync(fullPath)) {
try {
const content = readFileSync(fullPath);
const ext = filePath.split('.').pop() || 'html';
const mimeTypes = {
'html': 'text/html',
'js': 'application/javascript',
'css': 'text/css',
'json': 'application/json',
'png': 'image/png',
'jpg': 'image/jpeg',
'svg': 'image/svg+xml',
'woff': 'font/woff',
'woff2': 'font/woff2'
};
res.setHeader('Content-Type', mimeTypes[ext] || 'application/octet-stream');
res.end(content);
return;
} catch (e) {
// Fall through to next handler
}
}
}
}
next();
});
}
};
}
export default defineConfig({
plugins: [serveStaticApps(), sveltekit()]
});

12
docker-compose.yml Normal file
View file

@ -0,0 +1,12 @@
services:
homepage:
build:
context: ./apps/homepage
volumes:
- ./apps/homepage/build:/app/build:ro
ports:
- "3001:3000"
restart: unless-stopped
# Build locally: cd apps/homepage && npm run build
# Deploy: scp -r apps/homepage/build/* server:/var/www/build.valuecurve.co/

File diff suppressed because one or more lines are too long

57
init.sh Normal file
View file

@ -0,0 +1,57 @@
#!/bin/bash
# Build and deploy script for build.valuecurve.co
# Usage: ./init.sh [build|deploy|all]
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
APP_DIR="$SCRIPT_DIR/apps/homepage"
SERVER="root@46.224.190.110"
REMOTE_PATH="/var/www/build.valuecurve.co"
# Colors for output
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color
build() {
echo -e "${YELLOW}Building homepage...${NC}"
cd "$APP_DIR"
npm run build
echo -e "${GREEN}Build complete!${NC}"
}
deploy() {
echo -e "${YELLOW}Deploying to $SERVER...${NC}"
if [ ! -d "$APP_DIR/build" ]; then
echo -e "${RED}Error: Build folder not found. Run './init.sh build' first.${NC}"
exit 1
fi
scp -r "$APP_DIR/build/"* "$SERVER:$REMOTE_PATH/"
echo -e "${GREEN}Deployment complete!${NC}"
echo -e "Site live at: https://build.valuecurve.co"
}
case "${1:-all}" in
build)
build
;;
deploy)
deploy
;;
all)
build
deploy
;;
*)
echo "Usage: ./init.sh [build|deploy|all]"
echo " build - Build the SvelteKit app"
echo " deploy - Deploy build folder to server"
echo " all - Build and deploy (default)"
exit 1
;;
esac

534
posts/statistical-tests.qmd Normal file
View file

@ -0,0 +1,534 @@
---
title: "Types of Statistical Tests: A Comprehensive Guide"
format:
html:
toc: true
toc-location: right
toc-depth: 3
theme: cosmo
embed-resources: true
mainfont: "Helvetica Neue"
include-in-header:
- text: |
<style>
h1 { font-size: 2rem; }
h2 { font-size: 1.5rem; }
h3 { font-size: 1.25rem; }
h4 { font-size: 1.1rem; }
table { font-size: 0.85rem; }
table th, table td { padding: 6px 10px; }
table th { font-size: 0.8rem; }
</style>
---
Imagine a carpenter with only a hammer. Every problem becomes a nail, every solution involves pounding. The results would be disastrous—stripped screws, shattered glass, splintered wood. Statistics works the same way. Armed with only one test, researchers force every question into the same mold, producing unreliable answers and misleading conclusions. Mastering the diverse toolkit of statistical tests transforms you from a one-trick amateur into a skilled craftsman of data analysis.
## The Foundation: Why Different Tests Exist
Statistical tests are not interchangeable. Each is designed for specific data types, research questions, and assumptions. Using the wrong test is like measuring temperature with a ruler—the tool simply doesn't match the task.
Three fundamental questions guide test selection: What type of data do you have? What relationship are you investigating? What assumptions can your data satisfy? The answers to these questions narrow the field from dozens of potential tests to the one or two that fit your situation precisely.
Data types form the first filter. **Continuous data** (height, weight, temperature) can take any value within a range. **Categorical data** falls into distinct groups (gender, treatment type, survey responses). **Ordinal data** has categories with a meaningful order but no consistent intervals (satisfaction ratings, education levels). Each data type requires tests designed to handle its unique properties.
## Normality Tests: Checking Your Assumptions
Before selecting a statistical test, you must understand your data's distribution. Many powerful tests assume data follows a normal (bell-shaped) distribution. Violating this assumption can invalidate results entirely.
The **Shapiro-Wilk test** stands as the gold standard for normality testing with small to medium samples (n < 5000). It compares your data's distribution against a theoretical normal distribution, producing a W statistic between 0 and 1. Values close to 1 suggest normality; significantly lower values indicate departure from normality. Its power to detect non-normality exceeds most alternatives, making it the default choice for most applications.
The **D'Agostino-Pearson test** takes a different approach, examining two specific properties: skewness (asymmetry) and kurtosis (tail heaviness). By combining these measures, it identifies not just whether data is non-normal, but why. Is the distribution lopsided? Are the tails too heavy or too light? This diagnostic information guides decisions about data transformation or alternative test selection.
The **Kolmogorov-Smirnov test** offers flexibility that others lack. While typically used for normality testing, it can compare data against any theoretical distribution—exponential, uniform, Poisson, or custom distributions. This generality comes at a cost: it's less powerful than Shapiro-Wilk for detecting non-normality specifically.
The **Anderson-Darling test** improves upon Kolmogorov-Smirnov by weighting tail observations more heavily. Since many important phenomena manifest in distribution tails (extreme events, outliers), this sensitivity often proves valuable. It's particularly useful when tail behavior matters for your analysis.
Visual methods complement these formal tests. **Histograms** reveal distribution shape at a glance. **Q-Q plots** compare data quantiles against theoretical quantiles—points falling along a diagonal line indicate normality. **Box plots** display median, quartiles, and outliers compactly. No single method suffices; combining visual inspection with formal testing provides the most complete picture.
## Parametric Tests: Power Through Assumptions
Parametric tests assume data follows a specific distribution (usually normal) and estimate population parameters like means and variances. When assumptions hold, these tests offer maximum statistical power—the ability to detect real effects.
### T-Tests: Comparing Means
The **one-sample t-test** addresses a simple question: does my sample mean differ from a known or hypothesized value? A manufacturer might test whether average product weight equals the target specification. A teacher might assess whether class performance differs from the national average. The test calculates how many standard errors separate the sample mean from the hypothesized value, translating this distance into a probability.
The **two-sample independent t-test** compares means between two unrelated groups. Do men and women differ in height? Do treatment and control groups show different outcomes? The test assumes both groups are normally distributed with equal variances. When the equal variance assumption fails, **Welch's t-test** provides a robust alternative, adjusting degrees of freedom to account for variance differences. Many statisticians now recommend Welch's test as the default, since it performs well even when variances are equal.
The **paired t-test** handles related measurements—the same subjects measured twice, or naturally matched pairs. Before-and-after studies, twin comparisons, and left-right eye measurements all call for paired analysis. By focusing on within-pair differences rather than raw values, this test eliminates between-subject variability, dramatically increasing statistical power. An effect invisible to independent comparison often emerges clearly with paired analysis.
### ANOVA: Comparing Multiple Groups
When comparing three or more groups, multiple t-tests create problems. Each test carries a 5% false positive risk; conducting many tests accumulates this risk until false positives become likely. **Analysis of Variance (ANOVA)** solves this by testing all groups simultaneously.
**One-way ANOVA** compares means across multiple groups for a single factor. Do students from different schools perform differently? Does crop yield vary across fertilizer types? ANOVA partitions total variability into between-group and within-group components, asking whether between-group differences exceed what within-group variability would predict by chance.
A significant ANOVA result indicates that at least one group differs—but not which one. **Post-hoc tests** like Tukey's HSD, Bonferroni correction, or Scheffé's method identify specific group differences while controlling overall error rate.
**Levene's test** checks the equal variance assumption critical to ANOVA. When variances differ substantially, **Welch's ANOVA** provides a robust alternative that doesn't require this assumption.
## Non-Parametric Tests: Distribution-Free Alternatives
When data violates normality assumptions or consists of ranks and ratings, non-parametric tests provide reliable alternatives. These tests make fewer assumptions, trading some statistical power for broader applicability.
The **Mann-Whitney U test** (also called Wilcoxon rank-sum) serves as the non-parametric counterpart to the independent t-test. Rather than comparing means, it compares rank distributions between two groups. After combining and ranking all observations, it tests whether one group's ranks are systematically higher than the other's. This approach handles skewed distributions, ordinal data, and outliers gracefully.
The **Wilcoxon signed-rank test** parallels the paired t-test for non-normal data. It ranks the absolute differences between paired observations, then compares positive and negative rank sums. If treatment has no effect, positive and negative differences should balance; systematic imbalance suggests a real effect.
The **Kruskal-Wallis test** extends Mann-Whitney to three or more groups, serving as the non-parametric alternative to one-way ANOVA. It ranks all observations regardless of group membership, then tests whether mean ranks differ across groups. Like ANOVA, a significant result requires follow-up tests (typically Dunn's test) to identify which specific groups differ.
### Choosing Between Parametric and Non-Parametric
The decision isn't always straightforward. Parametric tests offer more power when assumptions hold, but non-parametric tests provide protection when they don't. Consider these guidelines:
Use parametric tests when data is continuous, approximately normal (or n > 30 per group), and variances are roughly equal. Use non-parametric tests when data is ordinal, clearly non-normal, contains significant outliers, or sample sizes are small and distribution unknown.
When uncertain, running both types of tests provides insight. If they agree, report the parametric result for its greater power. If they disagree, the non-parametric result is typically more trustworthy.
## Categorical Tests: Analyzing Frequencies
When both variables are categorical, entirely different tests apply. These analyze counts and proportions rather than means.
The **Chi-square test of independence** assesses whether two categorical variables are related. Is survival associated with passenger class? Does political affiliation relate to geographic region? The test compares observed cell frequencies in a contingency table against frequencies expected under independence. Large discrepancies suggest association.
Chi-square requires adequate sample sizes—expected frequencies should exceed 5 in each cell. When this condition fails, **Fisher's exact test** provides an exact probability rather than an approximation. Originally designed for 2×2 tables, extensions now handle larger tables, though computational demands increase rapidly.
## Correlation Tests: Measuring Relationships
Correlation quantifies the strength and direction of association between two continuous variables.
**Pearson correlation** measures linear relationships, producing the familiar r coefficient ranging from -1 to +1. Perfect positive correlation (r = 1) means variables move together proportionally; perfect negative correlation (r = -1) means they move oppositely; zero correlation indicates no linear relationship. Pearson assumes both variables are normally distributed and related linearly.
**Spearman correlation** measures monotonic relationships using ranks rather than raw values. It captures associations where variables consistently move together (or oppositely) without requiring a linear pattern. Robust to outliers and applicable to ordinal data, Spearman serves as the non-parametric alternative to Pearson.
**Kendall's tau** also measures monotonic association but uses a different approach: counting concordant versus discordant pairs of observations. More robust than Spearman with small samples or many tied values, Kendall's coefficient tends toward smaller absolute values than Spearman's, complicating direct comparison.
## Making the Right Choice: A Decision Framework
The following interactive fishbone diagram organizes statistical tests by category. **Click on any test** to see its description and when to use it.
```{ojs}
//| echo: false
// Test data with descriptions
testDescriptions = ({
t1: { name: 'One-Sample T-Test', description: 'Compares a sample mean to a known or hypothesized population value.', use: 'When you have one group and want to test if its mean differs from a specific value.', example: 'Testing if average product weight equals the target specification.', parametric: true },
t2: { name: 'Wilcoxon Signed-Rank', description: 'Non-parametric test comparing a sample to a hypothesized value using ranks.', use: 'When data is not normally distributed but you want to compare to a standard value.', example: 'Testing if median customer satisfaction differs from neutral.', parametric: false },
t3: { name: 'Mann-Whitney U', description: 'Compares distributions of two independent groups using ranks.', use: 'When comparing two groups with non-normal data or ordinal measurements.', example: 'Comparing pain ratings between treatment and placebo groups.', parametric: false },
t4: { name: 'Independent T-Test', description: 'Compares means of two independent groups assuming equal variances.', use: 'When comparing two unrelated groups with normal data and similar spreads.', example: 'Comparing test scores between two different teaching methods.', parametric: true },
t5: { name: "Welch's T-Test", description: 'Compares means of two independent groups without assuming equal variances.', use: 'When comparing two groups that may have different variability.', example: 'Comparing reaction times between young and elderly participants.', parametric: true },
t6: { name: 'Paired T-Test', description: 'Compares means of two related measurements on the same subjects.', use: 'When you have before-after measurements or matched pairs.', example: 'Testing if a training program improved employee performance.', parametric: true },
t7: { name: 'Wilcoxon Paired', description: 'Non-parametric test for paired data using ranks of differences.', use: 'When paired data is not normally distributed.', example: 'Comparing patient pain levels before and after treatment with skewed data.', parametric: false },
t8: { name: 'Kruskal-Wallis', description: 'Non-parametric comparison of three or more independent groups.', use: 'When comparing multiple groups with non-normal or ordinal data.', example: 'Comparing satisfaction ratings across four different product versions.', parametric: false },
t9: { name: 'One-Way ANOVA', description: 'Compares means across three or more groups simultaneously.', use: 'When comparing multiple groups with normal data and equal variances.', example: 'Testing if crop yields differ across three fertilizer types.', parametric: true },
t10: { name: "Welch's ANOVA", description: 'Robust ANOVA that does not assume equal variances across groups.', use: 'When comparing multiple groups that may have different variability.', example: 'Comparing salaries across departments with different spreads.', parametric: true },
t11: { name: 'Pearson Correlation', description: 'Measures the linear relationship between two continuous variables.', use: 'When assessing how strongly two variables move together linearly.', example: 'Examining the relationship between study hours and exam scores.', parametric: true },
t12: { name: 'Spearman Correlation', description: 'Measures monotonic relationships using ranks, robust to outliers.', use: 'When data is ordinal or the relationship is not linear.', example: 'Correlating education level with income category.', parametric: false },
t13: { name: 'Chi-Square Test', description: 'Tests association between two categorical variables.', use: 'When examining if two categorical variables are related.', example: 'Testing if smoking status is associated with disease incidence.', parametric: false },
t14: { name: "Fisher's Exact", description: 'Exact test for association in small sample contingency tables.', use: 'When expected cell counts are too small for chi-square.', example: 'Testing treatment effectiveness with only 20 patients total.', parametric: false }
})
mutable selectedTest = null
width = 1100
height = 580
// Category structure for fishbone
categories = [
{
name: "1 Sample vs Value",
color: "#e91e63",
tests: [
{ id: 't1', label: 'One-Sample T-Test', condition: 'Normal data' },
{ id: 't2', label: 'Wilcoxon Signed-Rank', condition: 'Non-normal' }
]
},
{
name: "2 Groups Independent",
color: "#9c27b0",
tests: [
{ id: 't4', label: 'Independent T-Test', condition: 'Normal, equal var' },
{ id: 't5', label: "Welch's T-Test", condition: 'Normal, unequal var' },
{ id: 't3', label: 'Mann-Whitney U', condition: 'Non-normal' }
]
},
{
name: "2 Groups Paired",
color: "#673ab7",
tests: [
{ id: 't6', label: 'Paired T-Test', condition: 'Normal data' },
{ id: 't7', label: 'Wilcoxon Paired', condition: 'Non-normal' }
]
},
{
name: "3+ Groups",
color: "#3f51b5",
tests: [
{ id: 't9', label: 'One-Way ANOVA', condition: 'Normal, equal var' },
{ id: 't10', label: "Welch's ANOVA", condition: 'Normal, unequal var' },
{ id: 't8', label: 'Kruskal-Wallis', condition: 'Non-normal' }
]
},
{
name: "Correlation",
color: "#00796b",
tests: [
{ id: 't11', label: 'Pearson', condition: 'Linear, normal' },
{ id: 't12', label: 'Spearman', condition: 'Monotonic/ordinal' }
]
},
{
name: "Categorical",
color: "#ff5722",
tests: [
{ id: 't13', label: 'Chi-Square', condition: 'Large sample (n>5)' },
{ id: 't14', label: "Fisher's Exact", condition: 'Small sample' }
]
}
]
{
// Create wrapper div for controls + SVG
const wrapper = d3.create("div")
.style("position", "relative");
// Zoom controls
const controls = wrapper.append("div")
.style("display", "flex")
.style("gap", "8px")
.style("margin-bottom", "10px")
.style("justify-content", "center");
const buttonStyle = `
padding: 8px 16px;
border: none;
border-radius: 6px;
background: #1565c0;
color: white;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
`;
const zoomInBtn = controls.append("button")
.attr("style", buttonStyle)
.text("+ Zoom In")
.on("mouseover", function() { d3.select(this).style("background", "#1976d2"); })
.on("mouseout", function() { d3.select(this).style("background", "#1565c0"); });
const zoomOutBtn = controls.append("button")
.attr("style", buttonStyle)
.text(" Zoom Out")
.on("mouseover", function() { d3.select(this).style("background", "#1976d2"); })
.on("mouseout", function() { d3.select(this).style("background", "#1565c0"); });
const resetBtn = controls.append("button")
.attr("style", buttonStyle.replace("#1565c0", "#546e7a").replace("#1976d2", "#607d8b"))
.text("Reset View")
.on("mouseover", function() { d3.select(this).style("background", "#607d8b"); })
.on("mouseout", function() { d3.select(this).style("background", "#546e7a"); });
const svg = wrapper.append("svg")
.attr("viewBox", [0, 0, width, height])
.attr("width", "100%")
.attr("height", 520)
.style("font-family", "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif")
.style("background", "linear-gradient(180deg, #fafbfc 0%, #f0f2f5 100%)")
.style("border-radius", "12px")
.style("box-shadow", "0 4px 12px rgba(0,0,0,0.08)")
.style("cursor", "grab");
const defs = svg.append("defs");
// Drop shadow
const filter = defs.append("filter")
.attr("id", "shadow")
.attr("x", "-20%").attr("y", "-20%")
.attr("width", "140%").attr("height", "140%");
filter.append("feDropShadow")
.attr("dx", "1").attr("dy", "2")
.attr("stdDeviation", "3")
.attr("flood-opacity", "0.12");
// Create container group for zoom/pan
const container = svg.append("g");
// Zoom behavior
const zoom = d3.zoom()
.scaleExtent([0.5, 3])
.on("zoom", (event) => {
container.attr("transform", event.transform);
});
svg.call(zoom);
// Button handlers
zoomInBtn.on("click", () => {
svg.transition().duration(300).call(zoom.scaleBy, 1.3);
});
zoomOutBtn.on("click", () => {
svg.transition().duration(300).call(zoom.scaleBy, 0.7);
});
resetBtn.on("click", () => {
svg.transition().duration(300).call(zoom.transform, d3.zoomIdentity);
});
// Main spine
const spineY = height / 2;
const spineStartX = 80;
const spineEndX = width - 80;
// Draw main spine (thick line)
container.append("line")
.attr("x1", spineStartX)
.attr("y1", spineY)
.attr("x2", spineEndX)
.attr("y2", spineY)
.attr("stroke", "#37474f")
.attr("stroke-width", 4)
.attr("stroke-linecap", "round");
// Head (effect) - "Which Statistical Test?"
const headGroup = container.append("g")
.attr("transform", `translate(${spineEndX + 10}, ${spineY})`);
headGroup.append("polygon")
.attr("points", "0,-35 100,-35 120,0 100,35 0,35")
.attr("fill", "#1565c0")
.attr("filter", "url(#shadow)");
headGroup.append("text")
.attr("x", 55)
.attr("y", -5)
.attr("text-anchor", "middle")
.attr("fill", "white")
.attr("font-size", "12px")
.attr("font-weight", "600")
.text("Which");
headGroup.append("text")
.attr("x", 55)
.attr("y", 12)
.attr("text-anchor", "middle")
.attr("fill", "white")
.attr("font-size", "12px")
.attr("font-weight", "600")
.text("Statistical Test?");
// Calculate positions for branches
const branchSpacing = (spineEndX - spineStartX - 40) / (categories.length);
const topCategories = categories.filter((_, i) => i % 2 === 0);
const bottomCategories = categories.filter((_, i) => i % 2 === 1);
// Draw branches
categories.forEach((cat, i) => {
const isTop = i % 2 === 0;
const branchX = spineStartX + 60 + (i * branchSpacing);
const branchEndY = isTop ? spineY - 180 : spineY + 180;
const direction = isTop ? -1 : 1;
// Main branch line
container.append("line")
.attr("x1", branchX)
.attr("y1", spineY)
.attr("x2", branchX)
.attr("y2", branchEndY)
.attr("stroke", cat.color)
.attr("stroke-width", 3)
.attr("stroke-linecap", "round");
// Category label box
const labelGroup = container.append("g")
.attr("transform", `translate(${branchX}, ${branchEndY + direction * 25})`);
labelGroup.append("rect")
.attr("x", -65)
.attr("y", -14)
.attr("width", 130)
.attr("height", 28)
.attr("rx", 14)
.attr("fill", cat.color)
.attr("filter", "url(#shadow)");
labelGroup.append("text")
.attr("text-anchor", "middle")
.attr("dominant-baseline", "middle")
.attr("fill", "white")
.attr("font-size", "11px")
.attr("font-weight", "600")
.text(cat.name);
// Draw test nodes along the branch
const testSpacing = 140 / (cat.tests.length + 1);
cat.tests.forEach((test, j) => {
const testY = spineY + direction * (40 + (j + 1) * testSpacing);
const testX = branchX + (isTop ? 70 : -70);
// Small branch to test
container.append("line")
.attr("x1", branchX)
.attr("y1", testY)
.attr("x2", testX - (isTop ? 5 : -5))
.attr("y2", testY)
.attr("stroke", cat.color)
.attr("stroke-width", 2)
.attr("opacity", 0.7);
// Test node group
const testGroup = container.append("g")
.attr("transform", `translate(${testX}, ${testY})`)
.style("cursor", "pointer")
.on("click", () => {
mutable selectedTest = testDescriptions[test.id];
})
.on("mouseover", function() {
d3.select(this).select("rect")
.transition().duration(150)
.attr("transform", "scale(1.05)")
.attr("stroke-width", 3);
})
.on("mouseout", function() {
d3.select(this).select("rect")
.transition().duration(150)
.attr("transform", "scale(1)")
.attr("stroke-width", 2);
});
// Test box
const boxWidth = 115;
testGroup.append("rect")
.attr("x", isTop ? 0 : -boxWidth)
.attr("y", -20)
.attr("width", boxWidth)
.attr("height", 40)
.attr("rx", 6)
.attr("fill", "white")
.attr("stroke", cat.color)
.attr("stroke-width", 2)
.attr("filter", "url(#shadow)");
// Test name
testGroup.append("text")
.attr("x", isTop ? boxWidth/2 : -boxWidth/2)
.attr("y", -4)
.attr("text-anchor", "middle")
.attr("fill", "#37474f")
.attr("font-size", "10px")
.attr("font-weight", "600")
.text(test.label);
// Condition (when to use)
testGroup.append("text")
.attr("x", isTop ? boxWidth/2 : -boxWidth/2)
.attr("y", 10)
.attr("text-anchor", "middle")
.attr("fill", "#455a64")
.attr("font-size", "9px")
.attr("font-weight", "500")
.text(test.condition);
});
});
// Title at the start
container.append("text")
.attr("x", 40)
.attr("y", spineY - 8)
.attr("text-anchor", "start")
.attr("fill", "#37474f")
.attr("font-size", "13px")
.attr("font-weight", "600")
.text("Research");
container.append("text")
.attr("x", 40)
.attr("y", spineY + 10)
.attr("text-anchor", "start")
.attr("fill", "#37474f")
.attr("font-size", "13px")
.attr("font-weight", "600")
.text("Goal");
// Legend
const legendY = height - 35;
container.append("text")
.attr("x", width / 2)
.attr("y", legendY)
.attr("text-anchor", "middle")
.attr("fill", "#546e7a")
.attr("font-size", "12px")
.attr("font-weight", "500")
.text("Click any test • Scroll to zoom • Drag to pan");
return wrapper.node();
}
```
```{ojs}
//| echo: false
{
if (selectedTest) {
return html`
<div style="background: linear-gradient(135deg, #ffffff, #f8f9fa); border: 1px solid #e3f2fd; border-radius: 10px; padding: 14px 16px; margin-top: 14px; box-shadow: 0 2px 8px rgba(0,0,0,0.06);">
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;">
<h4 style="margin: 0; color: #1565c0; font-size: 16px; font-weight: 600;">${selectedTest.name}</h4>
<span style="display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 10px; font-weight: 600; background: ${selectedTest.parametric ? '#e8f5e9' : '#fce4ec'}; color: ${selectedTest.parametric ? '#2e7d32' : '#c2185b'};">
${selectedTest.parametric ? 'Parametric' : 'Non-Parametric'}
</span>
</div>
<div style="display: grid; gap: 8px;">
<div style="background: white; padding: 8px 12px; border-radius: 6px; border-left: 3px solid #1976d2;">
<strong style="color: #455a64; font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px;">Description</strong>
<p style="margin: 2px 0 0 0; color: #37474f; font-size: 13px; line-height: 1.4;">${selectedTest.description}</p>
</div>
<div style="background: white; padding: 8px 12px; border-radius: 6px; border-left: 3px solid #43a047;">
<strong style="color: #455a64; font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px;">When to Use</strong>
<p style="margin: 2px 0 0 0; color: #37474f; font-size: 13px; line-height: 1.4;">${selectedTest.use}</p>
</div>
<div style="background: white; padding: 8px 12px; border-radius: 6px; border-left: 3px solid #fb8c00;">
<strong style="color: #455a64; font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px;">Example</strong>
<p style="margin: 2px 0 0 0; color: #37474f; font-size: 13px; line-height: 1.4;">${selectedTest.example}</p>
</div>
</div>
</div>
`;
} else {
return html`
<div style="background: linear-gradient(135deg, #e3f2fd, #f5f5f5); border: 1px dashed #90caf9; border-radius: 10px; padding: 20px; margin-top: 14px; text-align: center;">
<p style="margin: 0; color: #546e7a; font-size: 13px;">Click on any <span style="color: #1565c0; font-weight: 600;">test box</span> in the diagram above to see detailed information</p>
</div>
`;
}
}
```
### Quick Reference Table
| Scenario | Parametric Test | Non-Parametric Alternative |
|----------|-----------------|---------------------------|
| 1 sample vs known value | One-Sample T-Test | Wilcoxon Signed-Rank |
| 2 independent groups | Independent T-Test / Welch's | Mann-Whitney U |
| 2 paired/matched groups | Paired T-Test | Wilcoxon Signed-Rank |
| 3+ independent groups | One-Way ANOVA | Kruskal-Wallis |
| Correlation (continuous) | Pearson | Spearman / Kendall |
| Association (categorical) | Chi-Square | Fisher's Exact |
Selecting the appropriate test follows a logical sequence:
First, identify your research question. Are you comparing groups, measuring association, or testing against a known value? Are you examining one variable, two variables, or more?
Second, characterize your data. Is the outcome continuous, ordinal, or categorical? How many groups or variables are involved? Is the design independent or paired/related?
Third, check assumptions. Is the data approximately normal? Are variances equal across groups? Are expected frequencies sufficient for chi-square?
Fourth, select the test that matches your question, data type, and satisfied assumptions. When assumptions are violated, choose robust alternatives.
Finally, remember that statistical tests answer narrow questions. They indicate whether effects exist, not whether they matter. Always supplement significance tests with effect sizes, confidence intervals, and practical interpretation.
## Conclusion
The diversity of statistical tests reflects the diversity of research questions and data types we encounter. No single test serves all purposes; no universal approach handles all situations. The skilled analyst matches tools to tasks, selecting tests whose assumptions align with data characteristics and whose outputs address research questions.
This matching process requires both technical knowledge and practical judgment. Knowing what each test does, what it assumes, and when it fails empowers researchers to extract valid insights from data while avoiding the pitfalls of misapplied methods.
Statistical tests are not arbitrary rituals but carefully designed tools, each optimized for specific purposes. Understanding their logic—not just their mechanics—transforms test selection from cookbook following to principled reasoning. And principled reasoning, ultimately, is what separates meaningful analysis from statistical theater.

51
scripts/build.sh Executable file
View file

@ -0,0 +1,51 @@
#!/bin/bash
set -e # Exit on any error
echo "Building build.valuecurve.co..."
# Clean dist
rm -rf dist
mkdir -p dist
# 1. Build SvelteKit homepage
echo "Building homepage..."
(cd apps/homepage && npm run build)
# 2. Copy SvelteKit build (base site + listing pages)
echo "Copying SvelteKit build..."
cp -r apps/homepage/build/* dist/
# 3. Build flowchart app
echo "Building flowchart..."
(cd apps/flowchart && npm run build)
# 4. Merge flowchart into tools/flowchart/
mkdir -p dist/tools/flowchart
cp -r apps/flowchart/dist/* dist/tools/flowchart/
# 5. Build Quarto guide (if quarto is available)
if command -v quarto &> /dev/null; then
echo "Building statistical-tests guide..."
quarto render posts/statistical-tests.qmd
mkdir -p dist/guides/statistical-tests
cp _site/posts/statistical-tests.html dist/guides/statistical-tests/index.html
else
echo "Quarto not found, copying pre-built guide..."
if [ -d "guides/statistical-tests" ]; then
cp -r guides/statistical-tests dist/guides/
fi
fi
echo ""
echo "Build complete!"
echo ""
echo "dist/ contents:"
ls -la dist/
echo ""
echo "guides/:"
ls -la dist/guides/ 2>/dev/null || echo " (none)"
echo ""
echo "tools/:"
ls -la dist/tools/ 2>/dev/null || echo " (none)"
echo ""
echo "To deploy: ./scripts/deploy.sh"

23
scripts/deploy.sh Executable file
View file

@ -0,0 +1,23 @@
#!/bin/bash
set -e
# Ensure dist exists and has required files
if [ ! -f "dist/index.html" ]; then
echo "Error: dist/index.html not found. Run ./scripts/build.sh first."
exit 1
fi
if [ ! -d "dist/guides" ] || [ ! -d "dist/tools" ] || [ ! -d "dist/notebooks" ]; then
echo "Error: dist/ is incomplete. Run ./scripts/build.sh first."
exit 1
fi
echo "Deploying to VPS..."
echo "Contents:"
ls -la dist/
rsync -avz --delete dist/ root@46.224.190.110:/var/www/build.valuecurve.co/
echo ""
echo "Deploy complete!"
echo "Site live at: https://build.valuecurve.co"

50
scripts/dev.sh Executable file
View file

@ -0,0 +1,50 @@
#!/bin/bash
set -e
# Local development server for build.valuecurve.co
# Mirrors production: static files + API proxy
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
DIST_DIR="$PROJECT_DIR/dist"
# Configurable ports (can override with env vars)
CADDY_PORT=${CADDY_PORT:-8080}
API_PORT=${API_PORT:-8000}
if [ ! -d "$DIST_DIR" ]; then
echo "Error: dist/ not found. Run ./scripts/build.sh first."
exit 1
fi
# Generate Caddyfile dynamically (HTTP-only for local dev)
CADDYFILE=$(mktemp)
cat > "$CADDYFILE" << CADDY
{
auto_https off
}
:$CADDY_PORT {
root * "$DIST_DIR"
handle /api/* {
reverse_proxy localhost:$API_PORT
}
handle {
try_files {path} {path}/ {path}/index.html /index.html
file_server
}
}
CADDY
echo "Starting local dev server..."
echo " Static files: $DIST_DIR"
echo " Caddy: http://localhost:$CADDY_PORT"
echo " API proxy: localhost:$API_PORT"
echo ""
echo "Make sure backend is running on port $API_PORT"
echo "Press Ctrl+C to stop"
echo ""
caddy run --adapter caddyfile --config "$CADDYFILE"

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Statistical Test Decision Flowchart - Svelte</title>
<script type="module" crossorigin src="/flowchart/assets/index-C0Tb9hP5.js"></script>
<link rel="stylesheet" crossorigin href="/flowchart/assets/index-KvuvRHxr.css">
</head>
<body>
<div id="app"></div>
</body>
</html>