jekyll-theme-pca-cloud 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,16 @@
1
+ @import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;450;500;550;600;650;700&family=Manrope:wght@400;500;600;650;700;750;800&display=swap');
2
+ :root{font-family:'DM Sans',sans-serif;color:#223b4c;font-synthesis:none;font-size:13px}*{box-sizing:border-box}body{margin:0;background:#81adc9;min-height:100svh}button,input{font:inherit}button,a,label.upload{ -webkit-tap-highlight-color:transparent}button{cursor:pointer}button:focus-visible,a:focus-visible,input:focus-visible,.upload:focus-within{outline:2px solid #215b7d;outline-offset:5px}#sky{position:fixed;inset:0;width:100%;height:100%;cursor:grab;touch-action:none}#sky:active{cursor:grabbing}header{position:relative;display:flex;align-items:center;justify-content:space-between;padding:30px 44px;z-index:1}.brand{color:#f9fdff;display:flex;align-items:center;gap:10px;text-decoration:none;font-size:23px;letter-spacing:-1px;font-weight:700}.brand span{font-weight:400;margin-left:-7px}.brand svg{width:36px;height:28px;margin-right:2px}.header-note{font-size:12px;color:#f3f9fc;display:flex;align-items:center;gap:9px;letter-spacing:.1px}.live-dot{width:5px;height:5px;background:#e2f3e8;border-radius:50%}.glass-button{color:white;border:1px solid #ffffff55;border-radius:7px;background:#ffffff10;padding:10px 15px}.glass-button span{margin-left:16px}.glass-button:hover{background:#ffffff25}main{position:relative;min-height:calc(100svh - 153px);pointer-events:none}.intro{position:absolute;top:36px;left:5.2%;color:#fff}.eyebrow{font-size:10px;letter-spacing:2.4px;font-weight:600;opacity:.8}h1{font-family:Manrope,sans-serif;font-size:clamp(38px,4.1vw,62px);line-height:1.12;letter-spacing:-3px;font-weight:500;margin:21px 0 18px}.intro p{font-size:13px;line-height:1.8;color:#eef6fc;opacity:.85}.panel{pointer-events:auto;position:relative;margin-left:auto;margin-right:44px;top:22px;width:318px;background:linear-gradient(135deg,#f8fcf9ef,#f5f9f5e8);border:1px solid #ffffffbb;border-radius:12px;box-shadow:0 15px 55px #24415a15;backdrop-filter:blur(24px);overflow:hidden}.panel-heading{padding:21px 22px 18px;display:flex;justify-content:space-between;align-items:center;font-size:15px;font-weight:600}.pill{font-size:7px;letter-spacing:1px;border:1px solid #96a99c55;padding:5px 6px;border-radius:4px;color:#65816e}.control-section{padding:18px 22px;border-top:1px solid #688ba520}.section-label{display:flex;align-items:center;justify-content:space-between;font-size:9px;font-weight:600;letter-spacing:1.4px;color:#70828b;margin-bottom:17px}.text-button{border:0;background:none;color:#536e7d;font-size:10px;padding:0;letter-spacing:0}.text-button:hover{color:#102d41}.upload{border:1px dashed #a9bec5;border-radius:7px;display:flex;align-items:center;flex-direction:column;padding:17px 6px 15px;cursor:pointer;background:#ffffff22;transition:.2s}.upload:hover,.upload.dragover{background:#dceaf1;border-color:#4681a1}.upload svg{color:#6d8999;margin-bottom:10px}.upload strong{font-size:12px;font-weight:500;margin-bottom:5px}.upload span{font-size:10px;color:#7c8e98}.upload small{font-size:9px;color:#99a6ac;margin-top:9px}.dataset{display:flex;gap:10px;align-items:center;margin-top:16px}.file-icon{font-size:23px;color:#73948a}.dataset div{min-width:0}.dataset strong{font-size:10px;display:block;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:214px}.dataset small{font-size:9px;color:#8b9ba3;display:block;margin-top:4px}.loaded-dot{width:5px;height:5px;background:#76a389;border-radius:50%;margin-left:auto;flex-shrink:0}#message{font-size:10px;line-height:1.5;color:#647986;margin:10px 0 0}#message:empty{display:none}#message.error{color:#aa4935}.tiny{font-size:9px;letter-spacing:0;font-weight:400}.toggle-row{display:flex;align-items:center;cursor:pointer;position:relative;font-size:11px}.toggle-row small{display:block;color:#91a0a7;font-size:9px;margin-top:4px}.toggle-row input{position:absolute;right:0;opacity:0;width:31px;height:20px}.switch{margin-left:auto;width:29px;height:17px;border-radius:20px;background:#c1cbd0;padding:3px}.switch:after{content:'';display:block;width:11px;height:11px;border-radius:50%;background:white;transition:transform .2s}.toggle-row input:checked+.switch{background:#75938b}.toggle-row input:checked+.switch:after{transform:translateX(12px)}.toggle-row input:focus-visible+.switch{outline:2px solid #215b7d;outline-offset:4px}.variance-title{display:flex;justify-content:space-between;font-size:10px;margin:23px 0 10px;color:#788b96}.variance-title strong{color:#3d5968;font-weight:500}.variance-bar{height:5px;display:flex;gap:2px;background:#e0e7e7;border-radius:9px;overflow:hidden}.variance-bar i{height:100%;background:#658c92}.variance-bar i:nth-child(2){background:#9ab5bb}.variance-bar i:nth-child(3){background:#c4d4d6}.variance-legend{display:flex;justify-content:space-between;font-size:8px;color:#82959e;margin-top:10px}.variance-legend span{display:flex;align-items:center;gap:4px}.variance-legend i{width:4px;height:4px;border-radius:50%;background:#658c92}.variance-legend span:nth-child(2) i{background:#9ab5bb}.variance-legend span:nth-child(3) i{background:#c4d4d6}.variance-legend b{font-weight:400;color:#506d7b}.segmented{display:flex;background:#e4ebeb;border-radius:6px;padding:3px;margin-bottom:20px}.segmented button{width:50%;border:0;background:none;font-size:10px;color:#84969e;padding:8px;border-radius:4px}.segmented button.active{background:#fff;color:#385462;box-shadow:0 1px 4px #34526010}.slider-label{display:flex;justify-content:space-between;font-size:10px;margin:13px 0 6px}.slider-label output{color:#82969f;font-size:9px}input[type=range]{appearance:none;width:100%;height:3px;background:linear-gradient(to right,#809e9c 65%,#d9e2e3 65%);border-radius:5px;cursor:pointer;margin:6px 0 9px}input[type=range]::-webkit-slider-thumb{appearance:none;width:11px;height:11px;background:#f8fbfa;border:2px solid #88a3a0;border-radius:50%;box-shadow:0 0 0 3px #f6f9f7}input[type=range]::-moz-range-thumb{width:9px;height:9px;background:white;border:2px solid #88a3a0;border-radius:50%}.motion-row{display:flex;justify-content:space-between;align-items:center;margin-top:13px;font-size:10px;color:#6c828e}.motion-row label{display:flex;align-items:center;gap:6px;cursor:pointer}.motion-row input{accent-color:#77958e;width:11px;height:11px;margin:0}.panel-footer{border-top:1px solid #688ba520;padding:13px;text-align:center;font-size:9px;color:#8fa0a6}.panel-footer span{margin-right:5px}.cloud-caption{position:absolute;top:76%;left:23%;display:flex;align-items:center;gap:10px;color:#f2f8fb;font-size:8px;letter-spacing:1.9px}.caption-line{width:26px;height:1px;background:#ffffff80}.view-tools{pointer-events:auto;position:absolute;bottom:51px;left:calc(50% - 200px);display:flex;align-items:center;gap:15px;border:1px solid #ffffff55;background:#ffffff15;backdrop-filter:blur(8px);padding:4px 6px;border-radius:7px;color:#fff}.view-tools button{border:0;background:none;color:#fff;width:26px;height:26px;font-size:19px}.view-tools span{font-size:10px;width:35px;text-align:center}.interaction-hint{position:absolute;bottom:25px;left:calc(50% - 221px);color:#f0f6f9b0;font-size:9px;letter-spacing:.3px}.interaction-hint span{margin-right:8px;font-size:14px}.interaction-hint i{margin:0 9px;font-style:normal}footer{position:relative;display:flex;align-items:center;justify-content:space-between;padding:25px 44px;color:#4e768b;font-size:9px}footer button{border:0;background:none;color:#4e768b;font-size:10px}footer button span{margin-left:8px}.coordinates{font-size:8px;letter-spacing:2px}dialog{border:1px solid white;border-radius:14px;background:#f3f8f6;color:#355365;max-width:480px;padding:36px;box-shadow:0 30px 100px #16354a55}dialog::backdrop{background:#173d5766;backdrop-filter:blur(8px)}dialog h2{font-family:Manrope,sans-serif;font-size:29px;letter-spacing:-1px;font-weight:500}dialog p{line-height:1.8;font-size:12px}dialog .dialog-note{font-size:10px;color:#81949d}#close-about{position:absolute;right:16px;top:12px;font-size:24px;border:0;background:none;color:#6e8590}@media(min-height:850px){.panel{top:46px}.intro{top:63px}.control-section{padding-top:22px;padding-bottom:22px}}@media(max-width:900px){header{padding:22px 25px}.header-note{display:none}.panel{margin-right:24px;width:290px}.intro{left:28px}h1{font-size:43px}.intro p{max-width:250px}.cloud-caption{left:10%;font-size:7px}.view-tools{left:25%}.interaction-hint{left:21%}}@media(max-width:640px){header{padding:20px}.brand{font-size:21px}.glass-button{font-size:11px}.intro{top:16px;left:24px}h1{font-size:40px;letter-spacing:-2px;margin-top:15px}.intro p{font-size:11px}.eyebrow{font-size:8px}.panel{top:0;margin:0 16px;width:calc(100% - 32px);position:relative;transform:none}main{padding-top:540px;min-height:0}.cloud-caption{top:448px;left:50%;transform:translateX(-50%);white-space:nowrap;font-size:6px}.view-tools{top:473px;bottom:auto;left:50%;transform:translateX(-50%)}.interaction-hint{display:none}footer{padding:24px 20px}.coordinates{display:none}.control-section{padding:19px 22px}.panel-heading{padding:20px 22px}#sky{position:fixed}.upload{padding:20px}.dataset strong{max-width:260px}}
3
+ .kaggle-import{margin-top:15px;font-size:10px;border-top:1px solid #688ba520;padding-top:13px}.kaggle-import summary{cursor:pointer;color:#536e7d}.kaggle-import summary span{float:right}.kaggle-import label{display:block;margin:13px 0 6px;color:#6c828e}.kaggle-import input,.kaggle-import select{width:100%;padding:9px;border:1px solid #b7c9ce;background:#fff9;border-radius:5px;font:inherit;color:#355365}.kaggle-actions{display:flex;gap:8px;margin-top:9px}.kaggle-import button{border:1px solid #b7c9ce;border-radius:5px;padding:8px;background:#e4edeb;color:#355365;font-size:10px}.kaggle-import button:disabled{opacity:.5;cursor:wait}#kaggle-load{margin-top:9px;width:100%}#kaggle-status{font-size:9px;line-height:1.6;color:#728892;margin-bottom:0}#kaggle-source{display:block;margin-top:10px;color:#456f81}#kaggle-source[hidden]{display:none}
4
+ .projection-label{display:block;font-size:10px;margin:12px 0 7px;color:#6c828e}.projection-select{width:100%;padding:9px;border:1px solid #b7c9ce;background:#fff9;border-radius:5px;font:inherit;font-size:11px;color:#355365}.projection-note{font-size:9px;line-height:1.6;color:#728892;margin:10px 0 15px}.projection-note:empty{display:none}#pca-options,#tsne-options{margin-bottom:18px}#pca-metrics.emphasize-reconstruction{display:flex;flex-direction:column}#pca-metrics.emphasize-reconstruction[hidden]{display:none}#pca-metrics.emphasize-reconstruction #reconstruction-metric{order:-1}#reconstruction-metric .variance-title{margin-bottom:0}#reconstruction-metric .projection-note{margin-bottom:0}@media(min-width:641px){.panel{max-height:calc(100svh - 150px);overflow-y:auto;scrollbar-width:thin}}
5
+ .learn-button{display:flex;align-items:center;justify-content:space-between;width:100%;margin-top:17px;padding:11px 13px;border:1px solid #b7c9ce;border-radius:6px;background:#e4edeb;color:#355365;font:inherit;font-size:11px}.learn-button:hover{background:#d5e6e3}.close-dialog{position:absolute;right:16px;top:12px;font-size:24px;border:0;background:none;color:#6e8590}#learn-drawer{position:fixed;z-index:5;right:38px;top:16px;bottom:16px;width:min(440px,48vw);overflow-y:auto;padding:30px 24px;background:#f3f8f6f5;border:1px solid #ffffffaa;border-radius:14px 0 0 14px;box-shadow:-12px 0 50px #16354a22;backdrop-filter:blur(20px);transform:translateX(calc(100% + 40px));visibility:hidden;transition:transform .25s ease,visibility .25s}#learn-drawer h2{margin:17px 0 9px}#learn-drawer .learn-subtitle{color:#637d88;font-size:11px;margin-bottom:25px}.learn-section{border-top:1px solid #cbdadd;padding:20px 0}.learn-section h3{font-family:Manrope,sans-serif;font-size:18px;font-weight:600;margin:0 0 10px}.learn-section p{margin:10px 0;line-height:1.7}.learn-section details{margin-top:14px;font-size:12px}.learn-section summary{cursor:pointer;color:#466f7c;font-weight:500}.learn-section .math-formula{font-family:ui-monospace,SFMono-Regular,monospace;font-size:12px;line-height:1.9;background:#e7efed;padding:12px;border-radius:6px;overflow-wrap:anywhere}.learn-section .math-insight{background:#e2eeeb;border-left:3px solid #658c92;border-radius:3px;padding:12px 14px}.learn-section .chart-key{display:flex;flex-wrap:wrap;gap:8px 18px;font-size:10px;color:#647d87}.learn-section svg{display:block;width:100%;height:auto;background:#ffffff70;border-radius:7px}.learn-section svg text{font-family:'DM Sans',sans-serif;font-size:10px;fill:#536e7d}.chart-grid{stroke:#dce6e7;stroke-width:1}.chart-reference{stroke:#91a6a9;stroke-width:1;stroke-dasharray:4 4}.chart-bar{fill:#c4d4d6}.chart-bar.retained{fill:#658c92}.chart-cumulative{fill:none;stroke:#345d78;stroke-width:2}.chart-point{fill:#345d78}.lesson-axis{stroke:#658c92;stroke-width:2}.lesson-reconstructed{fill:#f3f8f6;stroke:#658c92;stroke-width:1.5}.lesson-toggle{display:flex;align-items:center;gap:8px;margin:10px 0 18px;font-size:12px}.lesson-toggle input{accent-color:#658c92}.loading-table{width:100%;border-collapse:collapse;font-size:11px}.loading-table caption{text-align:left;margin:8px 0 13px;color:#637d88}.loading-table th{text-align:left;font-weight:500;font-size:10px;padding:8px;border-bottom:1px solid #cbdadd}.loading-table td{padding:8px;border-bottom:1px solid #e0e9e9;overflow-wrap:anywhere}.loading-table td:first-child{width:48%}.loading-table .top-loading{background:#e5efec}.loading-track{display:inline-block;position:relative;width:58%;height:12px;margin-right:10px;vertical-align:middle;background:#dbe5e5;border-radius:2px}.loading-track:after{content:'';position:absolute;left:50%;top:0;bottom:0;width:1px;background:#7a969a}.loading-track i{position:absolute;top:3px;height:6px;background:#658c92}#loading-chart{max-height:330px;overflow:auto}#lesson-calculation p{font-size:11px;overflow-wrap:anywhere}@media(max-width:640px){#learn-drawer{padding:32px 18px}#learn-drawer h2{font-size:24px}.learn-section h3{font-size:17px}.loading-track{width:42%}}
6
+
7
+ .workspace-tabs{position:fixed;z-index:6;right:0;top:35%;display:flex;flex-direction:column;gap:5px}.workspace-tab{writing-mode:vertical-rl;padding:16px 10px;border:1px solid #ffffffaa;border-radius:7px 0 0 7px;color:#456575;background:#e4edebe8;font-size:11px;letter-spacing:.6px}.workspace-tab.active{background:#f8fcf9;color:#203f50;box-shadow:-3px 0 12px #24415a15}.learning-open #learn-drawer{transform:translateX(0);visibility:visible}.learning-open .panel{visibility:hidden;pointer-events:none}.learning-open .intro,.learning-open .cloud-caption{display:none}.learning-open .view-tools{left:calc((100% - min(440px,48vw) - 42px)/2);transform:translateX(-50%)}.learning-open .interaction-hint{display:none}#learn-drawer h2{font-family:Manrope,sans-serif;font-size:26px;letter-spacing:-1px;font-weight:500}#learn-drawer p{font-size:12px;line-height:1.7}#learn-drawer .dialog-note{font-size:10px;color:#637d88}.learning-topics{display:flex;gap:3px;padding:4px;background:#e2ebea;border-radius:7px;margin:20px 0 12px}.learning-topics button{flex:1;padding:9px 3px;border:0;border-radius:5px;background:transparent;color:#617d88;font-size:11px}.learning-topics button[aria-selected=true]{background:white;color:#254c60;box-shadow:0 1px 4px #34526010}.learn-section[hidden]{display:none}.lesson-steps{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin-top:17px}.lesson-steps button,.step-navigation button{border:1px solid #bbced0;border-radius:5px;background:#ffffff77;color:#456575;padding:8px 5px;font-size:10px}.lesson-steps button[aria-current=step]{background:#365e70;color:white;border-color:#365e70}.step-navigation{display:flex;align-items:center;justify-content:space-between;gap:10px;margin:12px 0}.step-navigation span{font-size:10px;color:#617d88}.step-navigation button:disabled{opacity:.4;cursor:default}#learning-scene-hint{border-left:3px solid #d1ac62;background:#edf0e3;padding:12px;border-radius:3px}select:focus-visible,summary:focus-visible{outline:2px solid #215b7d;outline-offset:3px}
8
+ @media(max-width:640px){#learn-drawer{top:50svh;bottom:0;right:0;width:100%;border-radius:14px 14px 0 0;padding:24px 20px;transform:translateX(100%);max-height:50svh}.workspace-tabs{top:160px}.learning-open .view-tools{top:calc(50svh - 50px);left:50%;bottom:auto}.learning-open footer{visibility:hidden}.learning-open main{padding-top:50svh}}
9
+ @media(prefers-reduced-motion:reduce){#learn-drawer{transition:none}}
10
+
11
+ /* Shared sky-and-glass styling for ordinary Jekyll pages and posts. */
12
+ .content-page{background:linear-gradient(155deg,#6395b8,#a5c9dc 65%,#dce7de);background-attachment:fixed}
13
+ .content-page .content-shell{min-height:calc(100svh - 153px);padding:36px 24px;pointer-events:auto}
14
+ .content-card{max-width:800px;margin:0 auto;padding:clamp(24px,5vw,56px);background:#f3f8f6ed;border:1px solid #ffffffbb;border-radius:14px;box-shadow:0 15px 55px #24415a15;line-height:1.8;font-size:15px;overflow-wrap:anywhere}
15
+ .content-card h1{font-size:clamp(30px,4vw,46px);letter-spacing:-1.5px;margin-top:0}
16
+ .content-card a{color:#215b7d}.content-card img{max-width:100%;height:auto}.content-card pre{overflow-x:auto;padding:16px;background:#e7efed;border-radius:6px}.content-card blockquote{border-left:3px solid #658c92;margin-left:0;padding-left:20px}.content-card table{display:block;overflow-x:auto;border-collapse:collapse}.content-card th,.content-card td{padding:8px 12px;border-bottom:1px solid #cbdadd}.post-meta{color:#637d88;font-size:12px}
data/assets/js/app.js ADDED
@@ -0,0 +1,267 @@
1
+ import { pca, parseCSV, sampleData } from './pca.js';
2
+ import { datasetRef, listCSVFiles, downloadCSV } from './kaggle.js';
3
+ import { setupLearning, updateLearning, selectLessonPoint } from './learn.js';
4
+ const $ = id => document.getElementById(id);
5
+ const canvas = $('sky'), ctx = canvas.getContext('2d', { alpha: false });
6
+ let dataset = sampleData(), points = [], width = 0, height = 0, yaw = -.22, pitch = -.12, zoom = 1, mode = 'cloud';
7
+ let dragging = false, previous = null, lastTime = 0, density = .65, softness = .5;
8
+ let projectionWorker = null, projectionRun = 0, displayedMethod = 'pca';
9
+ let currentPCA = null;
10
+ let learningState = { open: false }, lessonHits = [], savedCamera = null, pointerStart = null;
11
+ const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
12
+ $('rotate').checked = !reducedMotion;
13
+
14
+ // Pre-render softly illuminated vapor particles; depth ordering produces volume.
15
+ function sprite(shade) {
16
+ const c = document.createElement('canvas'); c.width = c.height = 128;
17
+ const g = c.getContext('2d');
18
+ const color = shade ? '170,193,207' : '255,255,248';
19
+ const gradient = g.createRadialGradient(57, 48, 4, 64, 64, 60);
20
+ gradient.addColorStop(0, `rgba(${color},.64)`); gradient.addColorStop(.35, `rgba(${color},.48)`);
21
+ gradient.addColorStop(.65, `rgba(${color},.19)`); gradient.addColorStop(1, `rgba(${color},0)`);
22
+ g.fillStyle = gradient; g.fillRect(0, 0, 128, 128); return c;
23
+ }
24
+ const lightSprite = sprite(false), shadowSprite = sprite(true);
25
+ function analyze(candidate = dataset) {
26
+ const result = pca(candidate.data, $('standardize').checked);
27
+ projectionWorker?.terminate();projectionWorker=null;const run=++projectionRun;
28
+ dataset = candidate;
29
+ currentPCA = result;
30
+ updateLearning(result, dataset, $('standardize').checked);
31
+ const isTSNE=$('projection-method').value==='tsne';
32
+ $('pca-options').hidden=isTSNE;$('pca-metrics').hidden=isTSNE;
33
+ $('tsne-options').hidden=!isTSNE;$('tsne-metrics').hidden=!isTSNE;
34
+ $('projection-status').textContent='';
35
+ $('reconstruction-value').textContent=`${(result.reconstructionError*100).toFixed(2)}%`;
36
+ $('dataset-info').textContent = `${dataset.data.length.toLocaleString()} rows · ${dataset.columns.length} numeric features`;
37
+ $('total-variance').textContent = `${(result.variance.reduce((s,v) => s+v,0)*100).toFixed(1)}%`;
38
+ result.variance.forEach((v,i) => { $(`pc-${i}`).textContent = `${(v*100).toFixed(1)}%`; $(`bar-${i}`).style.width = `${v*100}%`; });
39
+ const notes = [];
40
+ if (dataset.skipped) notes.push(`${dataset.skipped} incomplete or malformed rows skipped.`);
41
+ if (dataset.sampled) notes.push(`Evenly sampled ${dataset.sourceRows.toLocaleString()} rows to 2,500 for analysis.`);
42
+ if (dataset.columns.length === 2 && !isTSNE) notes.push('Two numeric features: PC3 is zero.');
43
+ message(notes.join(' '));
44
+ if(!isTSNE){setProjection(result.points,'pca');return;}
45
+ const count=Math.min(dataset.data.length,500);
46
+ const rows=Array.from({length:count},(_,i)=>dataset.data[Math.floor(i*dataset.data.length/count)]);
47
+ const perplexity=Math.max(1,Math.min(50,count-1,Number($('perplexity').value)||30));
48
+ $('perplexity').max=Math.min(50,count-1);$('perplexity').value=perplexity;
49
+ points=[];displayedMethod='tsne';document.querySelector('.coordinates').textContent='t-SNE 1 / 2 / 3';
50
+ $('tsne-kl').textContent='—';
51
+ const detail=`${count} of ${dataset.data.length} rows · perplexity ${perplexity}`;
52
+ $('projection-status').textContent=`Computing t-SNE · ${detail}…`;
53
+ const fail=error=>{
54
+ if(run!==projectionRun)return;
55
+ $('projection-method').value='pca';analyze();
56
+ $('projection-status').textContent=`t-SNE failed: ${error}. Showing PCA.`;
57
+ };
58
+ try {
59
+ projectionWorker=new Worker(new URL('./projection-worker.js',import.meta.url),{type:'module'});
60
+ projectionWorker.onmessage=({data})=>{
61
+ if(run!==projectionRun)return;
62
+ if(data.type==='progress')$('projection-status').textContent=`t-SNE ${data.iteration}/${data.iterations} · ${detail}`;
63
+ else if(data.type==='error')fail(data.message);
64
+ else if(data.type==='result'){
65
+ setProjection(data.result.points,'tsne');$('tsne-kl').textContent=data.result.kl.toFixed(4);
66
+ $('projection-status').textContent=`Finished 500 iterations · ${detail}.`;
67
+ projectionWorker.terminate();projectionWorker=null;
68
+ }
69
+ };
70
+ projectionWorker.onerror=()=>fail('background worker unavailable');
71
+ projectionWorker.postMessage({rows,options:{standardize:$('standardize').checked,perplexity}});
72
+ }catch(error){fail(error.message);}
73
+ }
74
+ function setProjection(projected,method){
75
+ const extent=Math.max(...projected.map(p=>Math.hypot(...p)))||1;
76
+ points=projected.map((p,i)=>({x:p[0]/extent,y:p[1]/extent,z:p[2]/extent,size:.75+((i*137)%100)/200}));
77
+ displayedMethod=method;document.querySelector('.coordinates').textContent=method==='pca'?'PC1 / PC2 / PC3':'t-SNE 1 / 2 / 3';
78
+ }
79
+ function message(text, error = false) { $('message').textContent = text; $('message').classList.toggle('error', error); }
80
+ function resize() {
81
+ width = innerWidth; height = innerHeight;
82
+ const ratio = Math.min(devicePixelRatio || 1, 2);
83
+ canvas.width = width*ratio; canvas.height = height*ratio; ctx.setTransform(ratio,0,0,ratio,0,0);
84
+ }
85
+ function background() {
86
+ const sky = ctx.createLinearGradient(0,0,width*.15,height);
87
+ sky.addColorStop(0,'#4e83a8'); sky.addColorStop(.45,'#8ab4cf'); sky.addColorStop(1,'#d3e5eb');
88
+ ctx.fillStyle = sky; ctx.fillRect(0,0,width,height);
89
+ const sun = ctx.createRadialGradient(width*.12,height*.06,0,width*.12,height*.06,width*.8);
90
+ sun.addColorStop(0,'#fff7d91c'); sun.addColorStop(1,'#ffffff00');ctx.fillStyle=sun;ctx.fillRect(0,0,width,height);
91
+ // Distant cirrus, kept subordinate to the actual data cloud.
92
+ ctx.globalAlpha=.07;
93
+ for(let i=0;i<17;i++) { const x=((i*317)%1100)/1100*width,y=height*(.77+Math.sin(i*2)*.07); ctx.drawImage(lightSprite,x-160,y-18,320,36); }
94
+ ctx.globalAlpha=1;
95
+ }
96
+ function render(time) {
97
+ const dt = Math.min((time-lastTime)/1000 || 0,.05);lastTime=time;
98
+ if ($('rotate').checked && !learningState.open && !dragging && !document.hidden) yaw += dt*.035;
99
+ background();
100
+ const mobile = width<=640;
101
+ const drawerWidth = learningState.open ? Math.min(440, width * .48) + 42 : 340;
102
+ const sceneWidth = width - drawerWidth;
103
+ const centerX=mobile?width*.5:sceneWidth*.5, centerY=mobile?(learningState.open?height*.24:405):height*.57;
104
+ const scale=(mobile?(learningState.open?Math.min(width*.42,height*.19):width*.54):Math.min(sceneWidth*.44,height*.42))*zoom;
105
+ const cy=Math.cos(yaw), sy=Math.sin(yaw), cp=Math.cos(pitch), sp=Math.sin(pitch);
106
+ const toScreen = p => {
107
+ const x=p.x*cy+p.z*sy,z=-p.x*sy+p.z*cy,y=p.y*cp-z*sp,depth=p.y*sp+z*cp;
108
+ const perspective=3/(3-depth);
109
+ return {...p,x:centerX+x*scale*perspective,y:centerY-y*scale*perspective,z:depth,size:(p.size || 1)*perspective,light:y};
110
+ };
111
+ const walkthrough = learningState.open && learningState.topic === 'walkthrough';
112
+ const activePoints = walkthrough ? learningState.geometry.points.map(([x,y,z],index)=>({x,y,z,index,size:2})) : points;
113
+ const projected=activePoints.map((p,index)=>toScreen({...p,index})).sort((a,b)=>a.z-b.z);
114
+ lessonHits = walkthrough ? projected : [];
115
+ if(mode==='cloud' && !learningState.open) {
116
+ const radius=scale*(.07+softness*.15)*Math.pow(1600/Math.max(points.length,30),.16);
117
+ for(const p of projected) {
118
+ const r=radius*p.size;
119
+ ctx.globalAlpha=(.08+density*.55)*Math.min(1.6,Math.sqrt(1600/points.length));
120
+ ctx.drawImage(shadowSprite,p.x-r,p.y-r*.75,r*2,r*1.7);
121
+ ctx.globalAlpha=(.06+density*.43)*(0.68+Math.max(-.4,Math.min(.7,p.light))*1.2);
122
+ ctx.drawImage(lightSprite,p.x-r*1.05,p.y-r,r*2,r*1.8);
123
+ }
124
+ } else {
125
+ for(const p of projected) {
126
+ const selected = walkthrough && p.index === learningState.selected;
127
+ ctx.globalAlpha=walkthrough?1:.65;
128
+ ctx.fillStyle=selected?'#ffe4a0':'#f8fcff';
129
+ ctx.beginPath();ctx.arc(p.x,p.y,selected?7:Math.max(2,2*p.size*zoom),0,Math.PI*2);ctx.fill();
130
+ if(walkthrough){ctx.font='600 13px sans-serif';ctx.fillText('ABCD'[p.index],p.x+11,p.y-9);}
131
+ }
132
+ ctx.globalAlpha=.6;ctx.strokeStyle='#ffffff';ctx.lineWidth=1;ctx.font='9px sans-serif';
133
+ if(!walkthrough) [[1,0,0,'PC1'],[0,1,0,'PC2'],[0,0,1,'PC3']].forEach(([a,b,c,label],index)=>{
134
+ if(displayedMethod==='tsne')label=label.replace('PC','t-SNE ');
135
+ const highlight=learningState.open && (learningState.topic==='variance'||index===learningState.component);
136
+ ctx.globalAlpha=highlight?1:.45;ctx.strokeStyle=highlight?'#ffe4a0':'#fff';ctx.fillStyle=ctx.strokeStyle;ctx.lineWidth=highlight?3:1;
137
+ const end=toScreen({x:a*.9,y:b*.9,z:c*.9});
138
+ ctx.beginPath();ctx.moveTo(centerX,centerY);ctx.lineTo(end.x,end.y);ctx.stroke();ctx.fillText(label,end.x+5,end.y-5);
139
+ });
140
+ }
141
+ if(walkthrough) {
142
+ const {geometry,lesson,step,selected}=learningState;
143
+ const screen = row => toScreen({x:row[0],y:row[1],z:row[2]||0});
144
+ const line = (a,b,color,dashed=false,width=1.5) => {
145
+ ctx.globalAlpha=1;ctx.strokeStyle=color;ctx.lineWidth=width;ctx.setLineDash(dashed?[5,5]:[]);
146
+ const start=screen(a),end=screen(b);ctx.beginPath();ctx.moveTo(start.x,start.y);ctx.lineTo(end.x,end.y);ctx.stroke();ctx.setLineDash([]);
147
+ return [start,end];
148
+ };
149
+ const outline = (row,label) => {
150
+ const p=screen(row);ctx.strokeStyle='#ffe4a0';ctx.lineWidth=1.5;ctx.beginPath();ctx.arc(p.x,p.y,5,0,Math.PI*2);ctx.stroke();
151
+ if(label){ctx.fillStyle='#fff';ctx.font='11px sans-serif';ctx.fillText(label,p.x+9,p.y+15);}
152
+ };
153
+ // These are feature-space axes, not the PCA score axes of the normal cloud.
154
+ [[.9,0,0,'x₁'],[0,.9,0,'x₂']].forEach(([x,y,z,label])=>{
155
+ const [,end]=line([0,0,0],[x,y,z],step<2?'#ffe4a0':'#ffffff60');
156
+ ctx.fillStyle='#fff';ctx.font='12px sans-serif';ctx.fillText(label,end.x+6,end.y-5);
157
+ });
158
+ if(step===0) {
159
+ geometry.original.forEach((row,i)=>{
160
+ const [start,end]=line(row,geometry.points[i],'#ffe4a0',true);
161
+ const angle=Math.atan2(end.y-start.y,end.x-start.x);
162
+ ctx.beginPath();ctx.moveTo(end.x,end.y);ctx.lineTo(end.x-8*Math.cos(angle-.4),end.y-8*Math.sin(angle-.4));ctx.moveTo(end.x,end.y);ctx.lineTo(end.x-8*Math.cos(angle+.4),end.y-8*Math.sin(angle+.4));ctx.stroke();
163
+ outline(row,`${'ABCD'[i]} original`);
164
+ });
165
+ outline(geometry.mean,'mean');outline([0,0,0],'zero');
166
+ }
167
+ if(step>=2) {
168
+ const [vx,vy]=lesson.direction;
169
+ [[vx,vy,'PC1'],[-vy,vx,'PC2']].forEach(([x,y,label],i)=>{
170
+ const [,end]=line([-.85*x,-.85*y,0],[.85*x,.85*y,0],i===0?'#ffe4a0':'#ffffff70',false,i===0?3:1.5);
171
+ ctx.fillStyle=i===0?'#ffe4a0':'#fff';ctx.font='600 13px sans-serif';ctx.fillText(label,end.x+8,end.y-5);
172
+ });
173
+ }
174
+ if(step>=4) {
175
+ const indices=step===5?[0,1,2,3]:[selected];
176
+ indices.forEach(i=>{line(geometry.points[i],geometry.reconstructed[i],i===selected?'#ffe4a0':'#ffffffaa',true);outline(geometry.reconstructed[i],`${'ABCD'[i]} on PC1`);});
177
+ }
178
+ ctx.fillStyle='#f8fcff';ctx.font='12px sans-serif';ctx.globalAlpha=1;
179
+ const caption=mobile?`${step + 1}. ${['Center','Covariance','Directions','Variance','Project','Reconstruct'][step]} · drag to orbit`:`${['Center the measurements','Measure covariance','Find component directions','Compare retained variance','Project onto PC1','Reconstruct from PC1'][step]} · drag to orbit`;
180
+ ctx.fillText(caption,24,mobile?height*.5-62:height-76);
181
+ }
182
+ ctx.globalAlpha=1; requestAnimationFrame(render);
183
+ }
184
+ function setZoom(value) {zoom=Math.max(.4,Math.min(2.5,value));$('zoom-value').textContent=`${Math.round(zoom*100)}%`;}
185
+ canvas.addEventListener('pointerdown',e=>{dragging=true;previous=[e.clientX,e.clientY];pointerStart=previous;canvas.setPointerCapture(e.pointerId);});
186
+ canvas.addEventListener('pointermove',e=>{if(!dragging)return;yaw+=(e.clientX-previous[0])*.006;pitch=Math.max(-1.3,Math.min(1.3,pitch+(e.clientY-previous[1])*.006));previous=[e.clientX,e.clientY];});
187
+ for(const event of ['pointerup','pointercancel','lostpointercapture']) canvas.addEventListener(event,()=>{dragging=false;});
188
+ canvas.addEventListener('pointerup',e=>{
189
+ if(!pointerStart||Math.hypot(e.clientX-pointerStart[0],e.clientY-pointerStart[1])>6)return;
190
+ const point=lessonHits.filter(p=>Math.hypot(e.clientX-p.x,e.clientY-p.y)<18).sort((a,b)=>Math.hypot(e.clientX-a.x,e.clientY-a.y)-Math.hypot(e.clientX-b.x,e.clientY-b.y))[0];
191
+ if(point)selectLessonPoint(point.index);
192
+ });
193
+ canvas.addEventListener('wheel',e=>{e.preventDefault();setZoom(zoom*Math.exp(-e.deltaY*.001));},{passive:false});
194
+ $('zoom-in').onclick=()=>setZoom(zoom+.1);$('zoom-out').onclick=()=>setZoom(zoom-.1);
195
+ $('reset').onclick=()=>{yaw=-.22;pitch=-.12;setZoom(1);};
196
+ for(const name of ['cloud','point']) $(name+'-mode').onclick=()=>{mode=name==='cloud'?'cloud':'points';for(const n of ['cloud','point']) {$(n+'-mode').classList.toggle('active',n===name);$(n+'-mode').setAttribute('aria-pressed',String(n===name));}};
197
+ for(const name of ['density','softness']) {
198
+ const input=$(name);
199
+ input.oninput=()=>{const v=Number(input.value);$(name+'-value').value=v+'%';input.style.background=`linear-gradient(to right,#809e9c ${(v-10)/90*100}%,#d9e2e3 ${(v-10)/90*100}%)`;if(name==='density')density=v/100;else softness=v/100;};input.oninput();
200
+ }
201
+ $('standardize').onchange=()=>{try {analyze();}catch(e){$('standardize').checked=!$('standardize').checked;message(e.message,true);}};
202
+ $('projection-method').onchange=()=>analyze();
203
+ $('pca-objective').onchange=()=>{
204
+ const reconstruction=$('pca-objective').value==='reconstruction';
205
+ $('reconstruction-metric').hidden=!reconstruction;
206
+ $('pca-metrics').classList.toggle('emphasize-reconstruction',reconstruction);
207
+ };
208
+ $('perplexity').onchange=()=>analyze();$('rerun-tsne').onclick=()=>analyze();
209
+ async function importFile(file) {
210
+ if(!file)return;
211
+ if(file.size>5*1024*1024){message('Please choose a CSV smaller than 5 MB.',true);return;}
212
+ try {const candidate=parseCSV(await file.text());analyze(candidate);$('dataset-name').textContent=file.name;}catch(e){message(e.message,true);}
213
+ $('file').value='';
214
+ }
215
+ $('file').onchange=e=>importFile(e.target.files[0]);
216
+ $('dropzone').tabIndex=0;
217
+ $('dropzone').setAttribute('role','button');
218
+ $('dropzone').addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();$('file').click();}});
219
+ $('dropzone').addEventListener('dragover',e=>{e.preventDefault();$('dropzone').classList.add('dragover');});
220
+ $('dropzone').addEventListener('dragleave',()=>$('dropzone').classList.remove('dragover'));
221
+ $('dropzone').addEventListener('drop',e=>{e.preventDefault();$('dropzone').classList.remove('dragover');importFile(e.dataTransfer.files[0]);});
222
+ $('sample').onclick=()=>{analyze(sampleData());$('dataset-name').textContent='Atmospheric observations';};
223
+ $('export').onclick=()=>{canvas.toBlob(blob=>{if(!blob)return;const url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download='pca-cloud.png';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);});};
224
+ $('about').onclick=()=>$('about-dialog').showModal();$('close-about').onclick=()=>$('about-dialog').close();
225
+ $('about-dialog').addEventListener('click',e=>{if(e.target===$('about-dialog')){const r=e.target.getBoundingClientRect();if(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom)e.target.close();}});
226
+ let kaggleRef = '', kaggleRequest = 0, dataRequest = 0;
227
+ function kaggleStatus(text) { $('kaggle-status').textContent = text; }
228
+ function kaggleBusy(busy) { for (const id of ['kaggle-find','kaggle-demo','kaggle-load']) $(id).disabled = busy; }
229
+ $('kaggle-url').addEventListener('input',()=>{kaggleRequest++;kaggleRef='';$('kaggle-files').hidden=true;kaggleBusy(false);});
230
+ $('kaggle-form').onsubmit=async e=>{
231
+ e.preventDefault();const request=++kaggleRequest;
232
+ $('kaggle-files').hidden=true;kaggleBusy(true);kaggleStatus('Finding CSV files…');
233
+ try {
234
+ const ref=datasetRef($('kaggle-url').value),files=await listCSVFiles(ref);
235
+ if(request!==kaggleRequest)return;
236
+ if(!files.length)throw new Error('No CSV files under 5 MB found. Download a smaller CSV and upload it here.');
237
+ kaggleRef=ref;$('kaggle-file').replaceChildren(...files.map(name=>new Option(name,name)));
238
+ $('kaggle-files').hidden=false;kaggleStatus('Choose a CSV to turn into a cloud.');
239
+ } catch(error) {if(request===kaggleRequest)kaggleStatus(error.message);}
240
+ finally {if(request===kaggleRequest)kaggleBusy(false);}
241
+ };
242
+ async function loadKaggle(ref, filename) {
243
+ const request=++kaggleRequest, selection=++dataRequest;kaggleBusy(true);kaggleStatus('Downloading from Kaggle…');
244
+ try {
245
+ const text=await downloadCSV(ref,filename);
246
+ if(request!==kaggleRequest||selection!==dataRequest)return;
247
+ analyze(parseCSV(text));$('dataset-name').textContent=filename;
248
+ $('kaggle-source').href=`https://www.kaggle.com/datasets/${ref}`;$('kaggle-source').hidden=false;
249
+ kaggleStatus('Loaded from Kaggle. PCA runs locally in your browser.');
250
+ } catch(error) {if(request===kaggleRequest)kaggleStatus(error.message);}
251
+ finally {if(request===kaggleRequest)kaggleBusy(false);}
252
+ }
253
+ $('kaggle-load').onclick=()=>loadKaggle(kaggleRef,$('kaggle-file').value);
254
+ $('kaggle-demo').onclick=()=>{$('kaggle-url').value='https://www.kaggle.com/datasets/uciml/iris';$('kaggle-files').hidden=true;loadKaggle('uciml/iris','Iris.csv');};
255
+ for(const id of ['sample','file','dropzone']) $(id).addEventListener(id==='file'?'change':id==='dropzone'?'drop':'click',()=>{dataRequest++;$('kaggle-source').hidden=true;},true);
256
+ setupLearning(state => {
257
+ const wasWalkthrough=learningState.open&&learningState.topic==='walkthrough';
258
+ const isWalkthrough=state.open&&state.topic==='walkthrough';
259
+ if(isWalkthrough&&!wasWalkthrough){savedCamera={yaw,pitch,zoom};yaw=0;pitch=0;setZoom(1);}
260
+ if(wasWalkthrough&&!isWalkthrough&&savedCamera){({yaw,pitch}=savedCamera);setZoom(savedCamera.zoom);savedCamera=null;}
261
+ learningState=state;
262
+ document.querySelector('.coordinates').textContent=isWalkthrough?'x₁ / x₂ · FOUR-POINT LESSON':displayedMethod==='pca'?'PC1 / PC2 / PC3':'t-SNE 1 / 2 / 3';
263
+ canvas.setAttribute('aria-label',isWalkthrough?'Interactive four-point PCA walkthrough. Drag to rotate, scroll to zoom, or click a labeled point to inspect it.':`Interactive three-dimensional ${displayedMethod==='pca'?'PCA':'t-SNE'} cloud. Drag to rotate and scroll to zoom.`);
264
+ if(state.open&&$('projection-method').value==='tsne'){$('projection-method').value='pca';analyze();}
265
+ });
266
+ $('learn').addEventListener('click', () => updateLearning(currentPCA, dataset, $('standardize').checked));
267
+ window.addEventListener('resize',resize);analyze();resize();requestAnimationFrame(render);
@@ -0,0 +1,56 @@
1
+ const API = 'https://www.kaggle.com/api/v1/datasets';
2
+ export const MAX_BYTES = 5 * 1024 * 1024;
3
+
4
+ export function datasetRef(value) {
5
+ let ref = value.trim();
6
+ if (/^(www\.)?kaggle\.com\//i.test(ref)) ref = `https://${ref}`;
7
+ if (/^https?:/i.test(ref)) {
8
+ const url = new URL(ref);
9
+ if (url.protocol !== 'https:' || !['www.kaggle.com', 'kaggle.com'].includes(url.hostname)) throw new Error('Use a kaggle.com dataset link.');
10
+ ref = url.pathname.replace(/^\/datasets\//, '').replace(/\/$/, '');
11
+ }
12
+ if (!/^[a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+$/.test(ref)) throw new Error('Enter a Kaggle dataset URL or owner/dataset-name.');
13
+ return ref;
14
+ }
15
+
16
+ async function limitedFetch(url, limit, fetcher) {
17
+ let response;
18
+ try { response = await fetcher(url, { credentials: 'omit', signal: AbortSignal.timeout(30000) }); }
19
+ catch { throw new Error('Kaggle could not be reached. Try again, or download the CSV from Kaggle and upload it here.'); }
20
+ if (!response.ok) throw new Error(`Kaggle returned ${response.status}. Use a public dataset that does not require sign-in or accepting competition rules.`);
21
+ if (Number(response.headers.get('content-length')) > limit) { await response.body?.cancel(); throw new Error('This download is too large. Choose a CSV under 5 MB.'); }
22
+ const reader = response.body.getReader(), chunks = []; let size = 0;
23
+ try {
24
+ while (true) {
25
+ const { done, value } = await reader.read(); if (done) break;
26
+ size += value.byteLength;
27
+ if (size > limit) { await reader.cancel(); throw new Error('This download is too large. Choose a CSV under 5 MB.'); }
28
+ chunks.push(value);
29
+ }
30
+ } finally { reader.releaseLock(); }
31
+ const bytes = new Uint8Array(size); let offset = 0;
32
+ for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
33
+ if (bytes[0] === 80 && bytes[1] === 75) throw new Error('Kaggle returned a ZIP archive. Unzip it and upload the CSV here.');
34
+ const text = new TextDecoder().decode(bytes);
35
+ if (/^\s*</.test(text)) throw new Error('Kaggle returned a sign-in or verification page. Download the CSV on Kaggle, then upload it here.');
36
+ return text;
37
+ }
38
+
39
+ export async function listCSVFiles(ref, fetcher = fetch) {
40
+ ref = datasetRef(ref);
41
+ const files = []; let token = '';
42
+ for (let page = 0; page < 10; page++) {
43
+ const suffix = token ? `?pageToken=${encodeURIComponent(token)}` : '';
44
+ const data = JSON.parse(await limitedFetch(`${API}/list/${ref}${suffix}`, MAX_BYTES, fetcher));
45
+ if (data.errorMessage) throw new Error(data.errorMessage);
46
+ for (const file of data.datasetFiles || []) if (/\.csv$/i.test(file.name) && Number(file.totalBytes) <= MAX_BYTES) files.push(file.name);
47
+ token = data.nextPageToken; if (!token) return files;
48
+ }
49
+ throw new Error('This dataset has too many files to browse. Download your chosen CSV on Kaggle and upload it here.');
50
+ }
51
+
52
+ export async function downloadCSV(ref, filename, fetcher = fetch) {
53
+ ref = datasetRef(ref);
54
+ if (!filename || !/\.csv$/i.test(filename) || filename.split('/').some(p => !p || p === '.' || p === '..')) throw new Error('Choose a CSV file.');
55
+ return limitedFetch(`${API}/download/${ref}/${filename.split('/').map(encodeURIComponent).join('/')}`, MAX_BYTES, fetcher);
56
+ }
@@ -0,0 +1,248 @@
1
+ const $ = id => document.getElementById(id);
2
+ const percent = value => `${(value * 100).toFixed(2)}%`;
3
+ const format = value => Math.abs(value) < 1e-9 ? '0' : value.toFixed(3);
4
+ const basePoints = [[1, 3], [3, 1], [5, 3], [7, 5]];
5
+ const stepNames = ['Center', 'Covariance', 'Directions', 'Variance', 'Project', 'Reconstruct'];
6
+ const learning = { open: false, topic: 'variance', step: 0, selected: 3, component: 0 };
7
+ let onSceneChange = () => {};
8
+ let currentLesson;
9
+ let datasetSummary = '';
10
+
11
+ export function lessonGeometry(result, step) {
12
+ const original = result.data.map(row => row.map((value, j) => value / result.scales[j]));
13
+ const visible = step === 0 ? [...original, ...result.centered] : result.centered;
14
+ const extent = Math.max(...visible.map(row => Math.hypot(...row))) || 1;
15
+ const normalize = row => [row[0] / extent, row[1] / extent, 0];
16
+ return { points: result.centered.map(normalize), original: original.map(normalize),
17
+ reconstructed: result.reconstructed.map(normalize),
18
+ mean: normalize(result.mean.map((value, j) => value / result.scales[j])),
19
+ axes: [result.direction, [-result.direction[1], result.direction[0]]].map(normalize), extent };
20
+ }
21
+
22
+ function notifyScene() {
23
+ $('learn-dataset').textContent = learning.topic === 'walkthrough'
24
+ ? `Four-point lesson · 4 rows · population covariance · ${$('lesson-standardize').checked ? 'standardized' : 'centered'}`
25
+ : datasetSummary;
26
+ const hints = {
27
+ variance: 'The three PCA axes are highlighted in the sky. Drag to orbit and scroll to zoom.',
28
+ loadings: `PC${learning.component + 1} is highlighted in the sky. Select another component to connect its feature weights to its axis.`,
29
+ walkthrough: [
30
+ 'Center: outlined points show the original measurements. Arrows move them to their centered positions around zero.',
31
+ 'Covariance: the feature axes are highlighted. Inspect A–D to see how the two measurements vary together.',
32
+ 'Directions: PC1 and PC2 are drawn in the original feature plane. PC1 follows the greatest spread.',
33
+ 'Variance: PC1 is emphasized. Change units or standardization to see its direction and retained variance change.',
34
+ 'Project: the selected point connects to its coordinate on PC1. Click another point to inspect its projection.',
35
+ 'Reconstruct: outlined points are recovered from PC1. Dashed lines show the information lost for all four points.'
36
+ ][learning.step]
37
+ };
38
+ $('learning-scene-hint').textContent = hints[learning.topic];
39
+ onSceneChange({ ...learning, lesson: currentLesson,
40
+ geometry: currentLesson ? lessonGeometry(currentLesson, learning.step) : null });
41
+ }
42
+
43
+ export function selectLessonPoint(index) {
44
+ learning.selected = index;
45
+ $('lesson-point').value = String(index);
46
+ lesson();
47
+ }
48
+
49
+ export function fourPointAnalysis(multiplier = 1, standardize = false) {
50
+ const data = basePoints.map(([x, y]) => [x, y * multiplier]);
51
+ const mean = [4, 3 * multiplier];
52
+ const scales = standardize ? [Math.sqrt(5), Math.sqrt(2) * multiplier] : [1, 1];
53
+ const centered = data.map(row => row.map((value, j) => (value - mean[j]) / scales[j]));
54
+ const covariance = [0, 1].map(j => [0, 1].map(k => centered.reduce((sum, row) => sum + row[j] * row[k], 0) / 4));
55
+ const [[a, b], [, d]] = covariance;
56
+ const gap = Math.hypot(a - d, 2 * b);
57
+ const eigenvalues = [(a + d + gap) / 2, (a + d - gap) / 2];
58
+ const angle = Math.atan2(2 * b, a - d) / 2;
59
+ const direction = [Math.cos(angle), Math.sin(angle)];
60
+ const scores = centered.map(row => row[0] * direction[0] + row[1] * direction[1]);
61
+ const reconstructed = scores.map(score => direction.map(value => value * score));
62
+ return { data, mean, scales, centered, covariance, eigenvalues, direction, scores, reconstructed,
63
+ variance: eigenvalues.map(value => value / (a + d)) };
64
+ }
65
+
66
+ export function componentsForVariance(spectrum, threshold) {
67
+ let cumulative = 0;
68
+ const index = spectrum.findIndex(value => (cumulative += value) >= threshold - 1e-12);
69
+ return index < 0 ? spectrum.length : index + 1;
70
+ }
71
+
72
+ // SVG charts have fixed view boxes and responsive CSS dimensions. Dataset names
73
+ // and feature labels are inserted as text nodes, never parsed as markup.
74
+ function svgChart(title, width, height) {
75
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
76
+ svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
77
+ svg.setAttribute('role', 'img');
78
+ svg.setAttribute('aria-label', title);
79
+ return svg;
80
+ }
81
+ function element(svg, type, attributes, label) {
82
+ const node = document.createElementNS(svg.namespaceURI, type);
83
+ for (const [key, value] of Object.entries(attributes)) node.setAttribute(key, value);
84
+ if (label !== undefined) node.textContent = label;
85
+ svg.append(node);
86
+ return node;
87
+ }
88
+ function scree(spectrum) {
89
+ const svg = svgChart('Explained variance per component and cumulative explained variance', 620, 245);
90
+ const left = 42, top = 18, width = 550, height = 178;
91
+ const y = value => top + (1 - value) * height;
92
+ for (const value of [0, .5, .9, .95, 1]) {
93
+ element(svg, 'line', { x1: left, x2: left + width, y1: y(value), y2: y(value), class: value === .9 || value === .95 ? 'chart-reference' : 'chart-grid' });
94
+ element(svg, 'text', { x: left - 7, y: y(value) + 3, 'text-anchor': 'end' }, `${value * 100}%`);
95
+ }
96
+ let sum = 0;
97
+ const line = [];
98
+ spectrum.forEach((value, i) => {
99
+ const step = width / spectrum.length, x = left + (i + .5) * step;
100
+ const bar = element(svg, 'rect', { x: left + i * step + 1, y: y(value), width: Math.max(.5, step - 2), height: value * height, class: i < 3 ? 'chart-bar retained' : 'chart-bar' });
101
+ element(bar, 'title', {}, `PC${i + 1}: ${percent(value)}`);
102
+ line.push(`${x},${y(sum += value)}`);
103
+ if (spectrum.length <= 15 || i === 0 || i === spectrum.length - 1 || (i + 1) % 10 === 0)
104
+ element(svg, 'text', { x, y: 213, 'text-anchor': 'middle' }, String(i + 1));
105
+ });
106
+ element(svg, 'polyline', { points: line.join(' '), class: 'chart-cumulative' });
107
+ if (spectrum.length === 1) element(svg, 'circle', { cx: left + width / 2, cy: y(sum), r: 3, class: 'chart-point' });
108
+ element(svg, 'text', { x: left + width / 2, y: 238, 'text-anchor': 'middle' }, 'Principal component');
109
+ $('scree-chart').replaceChildren(svg);
110
+ const retained = spectrum.slice(0, 3).reduce((sum, value) => sum + value, 0);
111
+ $('scree-summary').textContent = `This view retains ${percent(retained)} of the variance and loses ${percent(Math.max(0, 1 - retained))}. ${componentsForVariance(spectrum, .9)} components reach 90%; ${componentsForVariance(spectrum, .95)} reach 95%.`;
112
+ }
113
+
114
+ function loadings(result, columns, index) {
115
+ const weights = result.components[index];
116
+ const top = weights.map((value, i) => ({ value, i })).sort((a, b) => Math.abs(b.value) - Math.abs(a.value)).slice(0, 3).map(item => item.i);
117
+ const table = document.createElement('table');
118
+ table.className = 'loading-table';
119
+ const caption = table.createCaption();
120
+ caption.textContent = `PC${index + 1} feature weights · ${percent(result.variance[index])} of variance`;
121
+ const header = table.createTHead().insertRow();
122
+ for (const label of ['Feature', 'Direction and weight']) {
123
+ const cell = document.createElement('th'); cell.scope = 'col'; cell.textContent = label; header.append(cell);
124
+ }
125
+ const body = table.createTBody();
126
+ weights.forEach((value, i) => {
127
+ const row = body.insertRow();
128
+ if (top.includes(i)) row.className = 'top-loading';
129
+ row.insertCell().textContent = columns[i];
130
+ const cell = row.insertCell(), track = document.createElement('span'), bar = document.createElement('i'), number = document.createElement('span');
131
+ track.className = 'loading-track'; track.setAttribute('aria-hidden', 'true');
132
+ bar.style.width = `${Math.abs(value) * 50}%`; bar.style.left = `${value < 0 ? 50 - Math.abs(value) * 50 : 50}%`;
133
+ track.append(bar); number.textContent = `${value > 0 ? '+' : ''}${format(value)}`; cell.append(track, number);
134
+ });
135
+ $('loading-chart').replaceChildren(table);
136
+ }
137
+
138
+ function lesson() {
139
+ const multiplier = Number($('lesson-scale').value), standardized = $('lesson-standardize').checked;
140
+ const result = fourPointAnalysis(multiplier, standardized);
141
+ currentLesson = result;
142
+ $('lesson-scale-value').value = `×${multiplier}`;
143
+ const svg = svgChart('Four centered points, their PC1 reconstructions, and discarded distances', 620, 285);
144
+ const extent = Math.max(...result.centered.flat().map(Math.abs)) * 1.25;
145
+ const scale = 110 / extent, x = value => 310 + value * scale, y = value => 135 - value * scale;
146
+ element(svg, 'line', { x1: 25, x2: 595, y1: 135, y2: 135, class: 'chart-grid' });
147
+ element(svg, 'line', { x1: 310, x2: 310, y1: 15, y2: 250, class: 'chart-grid' });
148
+ const [vx, vy] = result.direction;
149
+ element(svg, 'line', { x1: x(-extent * vx), y1: y(-extent * vy), x2: x(extent * vx), y2: y(extent * vy), class: 'lesson-axis' });
150
+ element(svg, 'text', { x: x(extent * vx) + 8, y: y(extent * vy) }, 'PC1');
151
+ result.centered.forEach(([a, b], i) => {
152
+ const [ra, rb] = result.reconstructed[i];
153
+ element(svg, 'line', { x1: x(a), y1: y(b), x2: x(ra), y2: y(rb), class: 'chart-reference' });
154
+ element(svg, 'circle', { cx: x(ra), cy: y(rb), r: 4, class: 'lesson-reconstructed' });
155
+ element(svg, 'circle', { cx: x(a), cy: y(b), r: 5, class: 'chart-point' });
156
+ element(svg, 'text', { x: x(a) + 8, y: y(b) - 7 }, `${'ABCD'[i]} (${format(a)}, ${format(b)})`);
157
+ });
158
+ element(svg, 'text', { x: 590, y: 150, 'text-anchor': 'end' }, standardized ? 'Standardized x₁' : 'Centered x₁');
159
+ element(svg, 'text', { x: 318, y: 14 }, standardized ? 'Standardized x₂' : 'Centered x₂');
160
+ element(svg, 'text', { x: 310, y: 277, 'text-anchor': 'middle' }, 'Both axes use the same scale; dashed lines show loss from keeping only PC1.');
161
+ $('lesson-chart').replaceChildren(svg);
162
+ const [[a, b], [, d]] = result.covariance;
163
+ const selected = learning.selected, pointName = 'ABCD'[selected];
164
+ const lines = [
165
+ `1. Center: mean = (${format(result.mean[0])}, ${format(result.mean[1])}).${standardized ? ` Divide by population standard deviations (${format(result.scales[0])}, ${format(result.scales[1])}).` : ''}`,
166
+ `2. Covariance: Σ = X̃ᵀX̃ / 4 = [[${format(a)}, ${format(b)}], [${format(b)}, ${format(d)}]]. Point ${pointName} contributes ${format(result.centered[selected][0] * result.centered[selected][1] / 4)} to the cross-covariance.`,
167
+ `3. Eigenvalues: λ₁ = ${format(result.eigenvalues[0])}; λ₂ = ${format(result.eigenvalues[1])}. Unit PC1 = (${format(vx)}, ${format(vy)}).`,
168
+ `4. Retained variance: PC1 = ${percent(result.variance[0])}; PC2 = ${percent(result.variance[1])}.`,
169
+ `5. Project ${pointName}: (${result.centered[selected].map(format).join(', ')}) · (${format(vx)}, ${format(vy)}) = ${format(result.scores[selected])}.`,
170
+ `6. Reconstruct ${pointName} from PC1: (${result.reconstructed[selected].map(format).join(', ')}). Relative squared error over all four points: ${percent(result.variance[1])}.`
171
+ ];
172
+ const p = document.createElement('p'); p.textContent = lines[learning.step];
173
+ $('lesson-calculation').replaceChildren(p);
174
+ $('lesson-progress').textContent = `${learning.step + 1} / ${stepNames.length}`;
175
+ $('lesson-previous').disabled = learning.step === 0;
176
+ $('lesson-next').disabled = learning.step === stepNames.length - 1;
177
+ [...$('lesson-steps').children].forEach((button, i) => button.setAttribute('aria-current', i === learning.step ? 'step' : 'false'));
178
+ notifyScene();
179
+ }
180
+
181
+ export function setupLearning(onChange) {
182
+ onSceneChange = onChange;
183
+ const setOpen = open => {
184
+ learning.open = open;
185
+ document.body.classList.toggle('learning-open', open);
186
+ $('learn-drawer').inert = !open;
187
+ $('studio-panel').inert = open;
188
+ $('learn').setAttribute('aria-expanded', String(open));
189
+ $('studio-tab').setAttribute('aria-pressed', String(!open));
190
+ $('studio-tab').classList.toggle('active', !open);
191
+ $('learn').classList.toggle('active', open);
192
+ if (!open) $('learn').focus();
193
+ notifyScene();
194
+ };
195
+ $('learn').onclick = () => setOpen(!learning.open);
196
+ $('studio-tab').onclick = () => setOpen(false);
197
+ $('close-learn').onclick = () => setOpen(false);
198
+ document.addEventListener('keydown', event => {
199
+ if (event.key === 'Escape' && learning.open && !document.querySelector('dialog[open]')) setOpen(false);
200
+ });
201
+ const topics = ['variance', 'loadings', 'walkthrough'];
202
+ const selectTopic = topic => {
203
+ learning.topic = topic;
204
+ for (const name of topics) {
205
+ const selected = name === topic;
206
+ $(`topic-${name}`).setAttribute('aria-selected', String(selected));
207
+ $(`topic-${name}`).tabIndex = selected ? 0 : -1;
208
+ $(`learning-${name}`).hidden = !selected;
209
+ }
210
+ notifyScene();
211
+ };
212
+ topics.forEach((topic, i) => {
213
+ $(`topic-${topic}`).onclick = () => selectTopic(topic);
214
+ $(`topic-${topic}`).onkeydown = event => {
215
+ if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
216
+ event.preventDefault();
217
+ const next = event.key === 'Home' ? 0 : event.key === 'End' ? 2 : (i + (event.key === 'ArrowRight' ? 1 : 2)) % 3;
218
+ selectTopic(topics[next]); $(`topic-${topics[next]}`).focus();
219
+ };
220
+ });
221
+ $('lesson-steps').replaceChildren(...stepNames.map((name, i) => {
222
+ const button = document.createElement('button'); button.textContent = `${i + 1}. ${name}`;
223
+ button.onclick = () => { learning.step = i; lesson(); }; return button;
224
+ }));
225
+ $('lesson-previous').onclick = () => { learning.step = Math.max(0, learning.step - 1); lesson(); };
226
+ $('lesson-next').onclick = () => { learning.step = Math.min(stepNames.length - 1, learning.step + 1); lesson(); };
227
+ $('lesson-point').onchange = () => selectLessonPoint(Number($('lesson-point').value));
228
+ $('lesson-scale').oninput = lesson;
229
+ $('lesson-standardize').onchange = lesson;
230
+ lesson();
231
+ }
232
+
233
+ export function updateLearning(result, dataset, standardized) {
234
+ datasetSummary = `${$('dataset-name').textContent} · ${dataset.data.length.toLocaleString()} analyzed rows · ${dataset.columns.length} features · ${standardized ? 'standardized' : 'centered, without standardization'}`;
235
+ scree(result.spectrum);
236
+ const selector = $('loading-component');
237
+ const index = Math.min(Number(selector.value) || 0, result.components.length - 1);
238
+ selector.replaceChildren(...result.components.map((_, i) => new Option(`PC${i + 1}`, i)));
239
+ selector.value = index;
240
+ learning.component = index;
241
+ selector.onchange = () => {
242
+ learning.component = Number(selector.value);
243
+ loadings(result, dataset.columns, learning.component);
244
+ notifyScene();
245
+ };
246
+ loadings(result, dataset.columns, index);
247
+ notifyScene();
248
+ }