Initial commit - build-valuecurve source code
This commit is contained in:
commit
9631e92147
47 changed files with 38268 additions and 0 deletions
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal 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
17
Caddyfile.local
Normal 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
15
_quarto.yml
Normal 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
12
apps/flowchart/index.html
Normal 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
1454
apps/flowchart/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
20
apps/flowchart/package.json
Normal file
20
apps/flowchart/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
415
apps/flowchart/src/App.svelte
Normal file
415
apps/flowchart/src/App.svelte
Normal 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>
|
||||||
8
apps/flowchart/src/main.js
Normal file
8
apps/flowchart/src/main.js
Normal 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;
|
||||||
5
apps/flowchart/svelte.config.js
Normal file
5
apps/flowchart/svelte.config.js
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
preprocess: vitePreprocess(),
|
||||||
|
};
|
||||||
7
apps/flowchart/vite.config.js
Normal file
7
apps/flowchart/vite.config.js
Normal 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
2556
apps/homepage/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
22
apps/homepage/package.json
Normal file
22
apps/homepage/package.json
Normal 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"
|
||||||
|
}
|
||||||
6
apps/homepage/postcss.config.js
Normal file
6
apps/homepage/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {}
|
||||||
|
}
|
||||||
|
};
|
||||||
25
apps/homepage/src/app.css
Normal file
25
apps/homepage/src/app.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/homepage/src/app.html
Normal file
13
apps/homepage/src/app.html
Normal 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>
|
||||||
38
apps/homepage/src/lib/components/FeatureCard.svelte
Normal file
38
apps/homepage/src/lib/components/FeatureCard.svelte
Normal 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>
|
||||||
14
apps/homepage/src/lib/components/Footer.svelte
Normal file
14
apps/homepage/src/lib/components/Footer.svelte
Normal 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>
|
||||||
56
apps/homepage/src/lib/components/Hero.svelte
Normal file
56
apps/homepage/src/lib/components/Hero.svelte
Normal 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>
|
||||||
38
apps/homepage/src/lib/components/Navbar.svelte
Normal file
38
apps/homepage/src/lib/components/Navbar.svelte
Normal 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>
|
||||||
93
apps/homepage/src/lib/components/NewsletterSection.svelte
Normal file
93
apps/homepage/src/lib/components/NewsletterSection.svelte
Normal 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 →
|
||||||
|
</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 →</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>
|
||||||
84
apps/homepage/src/lib/components/ResourceCard.svelte
Normal file
84
apps/homepage/src/lib/components/ResourceCard.svelte
Normal 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}
|
||||||
5
apps/homepage/src/routes/+layout.svelte
Normal file
5
apps/homepage/src/routes/+layout.svelte
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<script>
|
||||||
|
import '../app.css';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<slot />
|
||||||
2
apps/homepage/src/routes/+layout.ts
Normal file
2
apps/homepage/src/routes/+layout.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export const prerender = true;
|
||||||
|
export const trailingSlash = 'always';
|
||||||
77
apps/homepage/src/routes/+page.svelte
Normal file
77
apps/homepage/src/routes/+page.svelte
Normal 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>
|
||||||
111
apps/homepage/src/routes/guides/+page.svelte
Normal file
111
apps/homepage/src/routes/guides/+page.svelte
Normal 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>
|
||||||
163
apps/homepage/src/routes/notebooks/+page.svelte
Normal file
163
apps/homepage/src/routes/notebooks/+page.svelte
Normal 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>
|
||||||
135
apps/homepage/src/routes/tools/+page.svelte
Normal file
135
apps/homepage/src/routes/tools/+page.svelte
Normal 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>
|
||||||
548
apps/homepage/src/routes/tools/eda-gapminder/+page.svelte
Normal file
548
apps/homepage/src/routes/tools/eda-gapminder/+page.svelte
Normal 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">← 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>
|
||||||
576
apps/homepage/src/routes/tools/house-predictor/+page.svelte
Normal file
576
apps/homepage/src/routes/tools/house-predictor/+page.svelte
Normal 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">← 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>
|
||||||
915
apps/homepage/src/routes/tools/privacy-scanner/+page.svelte
Normal file
915
apps/homepage/src/routes/tools/privacy-scanner/+page.svelte
Normal 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">← 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>
|
||||||
BIN
apps/homepage/static/favicon.png
Normal file
BIN
apps/homepage/static/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 476 B |
BIN
apps/homepage/static/logo-favicon.png
Normal file
BIN
apps/homepage/static/logo-favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
BIN
apps/homepage/static/logo.png
Normal file
BIN
apps/homepage/static/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 128 KiB |
35
apps/homepage/svelte.config.js
Normal file
35
apps/homepage/svelte.config.js
Normal 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;
|
||||||
26
apps/homepage/tailwind.config.js
Normal file
26
apps/homepage/tailwind.config.js
Normal 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: []
|
||||||
|
};
|
||||||
14
apps/homepage/tsconfig.json
Normal file
14
apps/homepage/tsconfig.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
60
apps/homepage/vite.config.js
Normal file
60
apps/homepage/vite.config.js
Normal 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
12
docker-compose.yml
Normal 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/
|
||||||
29931
guides/statistical-tests/index.html
Normal file
29931
guides/statistical-tests/index.html
Normal file
File diff suppressed because one or more lines are too long
57
init.sh
Normal file
57
init.sh
Normal 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
534
posts/statistical-tests.qmd
Normal 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
51
scripts/build.sh
Executable 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
23
scripts/deploy.sh
Executable 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
50
scripts/dev.sh
Executable 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"
|
||||||
3
tools/flowchart/assets/index-C0Tb9hP5.js
Normal file
3
tools/flowchart/assets/index-C0Tb9hP5.js
Normal file
File diff suppressed because one or more lines are too long
1
tools/flowchart/assets/index-KvuvRHxr.css
Normal file
1
tools/flowchart/assets/index-KvuvRHxr.css
Normal file
File diff suppressed because one or more lines are too long
13
tools/flowchart/index.html
Normal file
13
tools/flowchart/index.html
Normal 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>
|
||||||
Loading…
Reference in a new issue