|
| 1 | +<!DOCTYPE html> |
| 2 | +<html lang="en"> |
| 3 | +<head> |
| 4 | + <meta charset="UTF-8" /> |
| 5 | + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| 6 | + <title>tsb — Categorical Ops</title> |
| 7 | + <style> |
| 8 | + :root { |
| 9 | + --bg: #0d1117; |
| 10 | + --surface: #161b22; |
| 11 | + --border: #30363d; |
| 12 | + --text: #e6edf3; |
| 13 | + --accent: #58a6ff; |
| 14 | + --green: #3fb950; |
| 15 | + --orange: #d29922; |
| 16 | + --red: #f85149; |
| 17 | + --font-mono: "Cascadia Code", "Fira Code", "JetBrains Mono", monospace; |
| 18 | + } |
| 19 | + * { box-sizing: border-box; margin: 0; padding: 0; } |
| 20 | + body { |
| 21 | + background: var(--bg); |
| 22 | + color: var(--text); |
| 23 | + font-family: system-ui, -apple-system, sans-serif; |
| 24 | + line-height: 1.6; |
| 25 | + padding: 2rem; |
| 26 | + max-width: 900px; |
| 27 | + margin: 0 auto; |
| 28 | + } |
| 29 | + a { color: var(--accent); } |
| 30 | + h1 { color: var(--accent); margin-bottom: 0.5rem; } |
| 31 | + h2 { margin-top: 0; margin-bottom: 0.5rem; font-size: 1.25rem; } |
| 32 | + p { color: #8b949e; margin-bottom: 1rem; } |
| 33 | + code { |
| 34 | + font-family: var(--font-mono); |
| 35 | + font-size: 0.875em; |
| 36 | + background: var(--surface); |
| 37 | + border: 1px solid var(--border); |
| 38 | + border-radius: 0.3rem; |
| 39 | + padding: 0.1rem 0.4rem; |
| 40 | + } |
| 41 | + .back { margin-bottom: 2rem; display: inline-block; } |
| 42 | + #playground-loading { |
| 43 | + position: fixed; |
| 44 | + inset: 0; |
| 45 | + background: rgba(13, 17, 23, 0.92); |
| 46 | + display: flex; |
| 47 | + flex-direction: column; |
| 48 | + align-items: center; |
| 49 | + justify-content: center; |
| 50 | + z-index: 1000; |
| 51 | + gap: 1rem; |
| 52 | + } |
| 53 | + .spinner { |
| 54 | + width: 40px; height: 40px; |
| 55 | + border: 3px solid var(--border); |
| 56 | + border-top-color: var(--accent); |
| 57 | + border-radius: 50%; |
| 58 | + animation: spin 0.8s linear infinite; |
| 59 | + } |
| 60 | + @keyframes spin { to { transform: rotate(360deg); } } |
| 61 | + .card { |
| 62 | + background: var(--surface); |
| 63 | + border: 1px solid var(--border); |
| 64 | + border-radius: 0.5rem; |
| 65 | + padding: 1.5rem; |
| 66 | + margin-bottom: 1.5rem; |
| 67 | + } |
| 68 | + textarea { |
| 69 | + width: 100%; |
| 70 | + min-height: 140px; |
| 71 | + font-family: var(--font-mono); |
| 72 | + font-size: 0.85rem; |
| 73 | + background: var(--bg); |
| 74 | + color: var(--text); |
| 75 | + border: 1px solid var(--border); |
| 76 | + border-radius: 0.35rem; |
| 77 | + padding: 0.75rem; |
| 78 | + resize: vertical; |
| 79 | + } |
| 80 | + button { |
| 81 | + background: var(--accent); |
| 82 | + color: #0d1117; |
| 83 | + border: none; |
| 84 | + border-radius: 0.35rem; |
| 85 | + padding: 0.5rem 1.25rem; |
| 86 | + font-weight: 600; |
| 87 | + cursor: pointer; |
| 88 | + margin-top: 0.75rem; |
| 89 | + } |
| 90 | + button:hover { opacity: 0.85; } |
| 91 | + .output { |
| 92 | + margin-top: 0.75rem; |
| 93 | + font-family: var(--font-mono); |
| 94 | + font-size: 0.85rem; |
| 95 | + white-space: pre-wrap; |
| 96 | + background: var(--bg); |
| 97 | + border: 1px solid var(--border); |
| 98 | + border-radius: 0.35rem; |
| 99 | + padding: 0.75rem; |
| 100 | + min-height: 3rem; |
| 101 | + } |
| 102 | + .output.error { color: var(--red); } |
| 103 | + .output.ok { color: var(--green); } |
| 104 | + table { border-collapse: collapse; font-family: var(--font-mono); font-size: 0.85rem; } |
| 105 | + th, td { border: 1px solid var(--border); padding: 0.3rem 0.75rem; } |
| 106 | + th { background: var(--surface); color: var(--accent); } |
| 107 | + </style> |
| 108 | +</head> |
| 109 | +<body> |
| 110 | +<div id="playground-loading"> |
| 111 | + <div class="spinner"></div> |
| 112 | + <span>Loading tsb runtime…</span> |
| 113 | +</div> |
| 114 | + |
| 115 | +<a class="back" href="index.html">← back to index</a> |
| 116 | +<h1>🏷️ Categorical Ops</h1> |
| 117 | +<p> |
| 118 | + Standalone categorical utility functions that complement the <code>Series.cat</code> accessor. |
| 119 | + Mirrors <code>pd.Categorical.from_codes</code>, set operations on categories, frequency helpers, |
| 120 | + and cross-tabulation. |
| 121 | +</p> |
| 122 | + |
| 123 | +<!-- ── catFromCodes ─────────────────────────────────────────────────── --> |
| 124 | +<div class="card"> |
| 125 | + <h2><code>catFromCodes(codes, categories, opts?)</code></h2> |
| 126 | + <p> |
| 127 | + Construct a categorical Series from integer codes (0-based) and a categories array. |
| 128 | + Code <code>-1</code> maps to <code>null</code> (missing). Mirrors |
| 129 | + <code>pd.Categorical.from_codes</code>. |
| 130 | + </p> |
| 131 | + <textarea id="code-from-codes">const { catFromCodes } = tsb; |
| 132 | + |
| 133 | +// Build a categorical from codes + category list |
| 134 | +const s = catFromCodes([0, 2, 1, -1, 0], ["red", "green", "blue"]); |
| 135 | +console.log("values:", s.toArray()); |
| 136 | +console.log("categories:", s.cat.categories.values); |
| 137 | +console.log("ordered:", s.cat.ordered); |
| 138 | + |
| 139 | +// With ordered=true |
| 140 | +const grade = catFromCodes([2, 0, 1, 2], ["F", "C", "A"], { ordered: true, name: "grade" }); |
| 141 | +console.log("grade values:", grade.toArray()); |
| 142 | +console.log("grade ordered:", grade.cat.ordered); |
| 143 | +console.log("grade codes back:", grade.cat.codes.toArray()); |
| 144 | +</textarea> |
| 145 | + <button onclick="run('code-from-codes','out-from-codes')">▶ Run</button> |
| 146 | + <div class="output" id="out-from-codes"></div> |
| 147 | +</div> |
| 148 | + |
| 149 | +<!-- ── Category set operations ──────────────────────────────────────── --> |
| 150 | +<div class="card"> |
| 151 | + <h2>Category set operations</h2> |
| 152 | + <p> |
| 153 | + <code>catUnionCategories</code>, <code>catIntersectCategories</code>, |
| 154 | + <code>catDiffCategories</code>, and <code>catEqualCategories</code> let you |
| 155 | + combine or compare the category sets of two Series. |
| 156 | + </p> |
| 157 | + <textarea id="code-set-ops">const { Series, catUnionCategories, catIntersectCategories, catDiffCategories, catEqualCategories } = tsb; |
| 158 | + |
| 159 | +const a = new Series({ data: ["x", "y", "z"] }).cat.setCategories(["x", "y", "z"]); |
| 160 | +const b = new Series({ data: ["y", "z", "w"] }).cat.setCategories(["y", "z", "w"]); |
| 161 | + |
| 162 | +const union = catUnionCategories(a, b); |
| 163 | +console.log("union cats:", union.cat.categories.values); // x y z w |
| 164 | + |
| 165 | +const inter = catIntersectCategories(a, b); |
| 166 | +console.log("intersect cats:", inter.cat.categories.values); // y z |
| 167 | +console.log("intersect vals:", inter.toArray()); // null y z |
| 168 | + |
| 169 | +const diff = catDiffCategories(a, b); |
| 170 | +console.log("diff cats:", diff.cat.categories.values); // x |
| 171 | +console.log("diff vals:", diff.toArray()); // x null null |
| 172 | + |
| 173 | +console.log("equal?", catEqualCategories(a, b)); // false |
| 174 | +const c = new Series({ data: [] }).cat.setCategories(["z", "x", "y"]); |
| 175 | +console.log("equal (diff order)?", catEqualCategories(a, c)); // true |
| 176 | +</textarea> |
| 177 | + <button onclick="run('code-set-ops','out-set-ops')">▶ Run</button> |
| 178 | + <div class="output" id="out-set-ops"></div> |
| 179 | +</div> |
| 180 | + |
| 181 | +<!-- ── catSortByFreq ────────────────────────────────────────────────── --> |
| 182 | +<div class="card"> |
| 183 | + <h2><code>catSortByFreq(series, opts?)</code></h2> |
| 184 | + <p> |
| 185 | + Reorder categories by their frequency in the data (most frequent first by default). |
| 186 | + Mirrors <code>s.cat.reorder_categories(s.value_counts().index)</code>. |
| 187 | + </p> |
| 188 | + <textarea id="code-sort-freq">const { Series, catSortByFreq } = tsb; |
| 189 | + |
| 190 | +const s = new Series({ data: ["b", "a", "b", "c", "b", "a"] }) |
| 191 | + .cat.setCategories(["a", "b", "c"]); |
| 192 | + |
| 193 | +const sorted = catSortByFreq(s); |
| 194 | +console.log("categories (most frequent first):", sorted.cat.categories.values); |
| 195 | +// b=3, a=2, c=1 → ["b", "a", "c"] |
| 196 | + |
| 197 | +const asc = catSortByFreq(s, { ascending: true }); |
| 198 | +console.log("categories (rarest first):", asc.cat.categories.values); |
| 199 | +// → ["c", "a", "b"] |
| 200 | +</textarea> |
| 201 | + <button onclick="run('code-sort-freq','out-sort-freq')">▶ Run</button> |
| 202 | + <div class="output" id="out-sort-freq"></div> |
| 203 | +</div> |
| 204 | + |
| 205 | +<!-- ── catToOrdinal ─────────────────────────────────────────────────── --> |
| 206 | +<div class="card"> |
| 207 | + <h2><code>catToOrdinal(series, order)</code></h2> |
| 208 | + <p> |
| 209 | + Create an ordered categorical from a Series using <code>order</code> to define both the |
| 210 | + category set and their rank. Values not in <code>order</code> become <code>null</code>. |
| 211 | + </p> |
| 212 | + <textarea id="code-to-ordinal">const { Series, catToOrdinal } = tsb; |
| 213 | + |
| 214 | +const s = new Series({ data: ["med", "low", "high", "med", "extreme"] }); |
| 215 | +const ord = catToOrdinal(s, ["low", "med", "high"]); |
| 216 | + |
| 217 | +console.log("values:", ord.toArray()); // med low high med null |
| 218 | +console.log("ordered:", ord.cat.ordered); // true |
| 219 | +console.log("categories:", ord.cat.categories.values); // low med high |
| 220 | +</textarea> |
| 221 | + <button onclick="run('code-to-ordinal','out-to-ordinal')">▶ Run</button> |
| 222 | + <div class="output" id="out-to-ordinal"></div> |
| 223 | +</div> |
| 224 | + |
| 225 | +<!-- ── catFreqTable ─────────────────────────────────────────────────── --> |
| 226 | +<div class="card"> |
| 227 | + <h2><code>catFreqTable(series)</code></h2> |
| 228 | + <p> |
| 229 | + Return a plain <code>Record<string, number></code> of counts per category. |
| 230 | + Zero-frequency categories are included. |
| 231 | + </p> |
| 232 | + <textarea id="code-freq-table">const { Series, catFreqTable } = tsb; |
| 233 | + |
| 234 | +const s = new Series({ data: ["b", "a", "b", null] }) |
| 235 | + .cat.setCategories(["a", "b", "c"]); |
| 236 | + |
| 237 | +const table = catFreqTable(s); |
| 238 | +console.log("frequency table:", table); |
| 239 | +// { a: 1, b: 2, c: 0 } |
| 240 | + |
| 241 | +// Sum equals non-null count |
| 242 | +const total = Object.values(table).reduce((sum, n) => sum + n, 0); |
| 243 | +console.log("total (non-null):", total); // 3 |
| 244 | +</textarea> |
| 245 | + <button onclick="run('code-freq-table','out-freq-table')">▶ Run</button> |
| 246 | + <div class="output" id="out-freq-table"></div> |
| 247 | +</div> |
| 248 | + |
| 249 | +<!-- ── catCrossTab ──────────────────────────────────────────────────── --> |
| 250 | +<div class="card"> |
| 251 | + <h2><code>catCrossTab(a, b, opts?)</code></h2> |
| 252 | + <p> |
| 253 | + Cross-tabulation of two categorical Series. Rows = <code>a</code>'s categories, |
| 254 | + columns = <code>b</code>'s categories, cells = co-occurrence counts. |
| 255 | + Supports margins and normalization. |
| 256 | + </p> |
| 257 | + <textarea id="code-crosstab">const { Series, catCrossTab } = tsb; |
| 258 | + |
| 259 | +const gender = new Series({ data: ["M","F","M","F","M","F","M"] }) |
| 260 | + .cat.setCategories(["M","F"]); |
| 261 | +const response = new Series({ data: ["Yes","No","Yes","Yes","No","Yes","Yes"] }) |
| 262 | + .cat.setCategories(["Yes","No"]); |
| 263 | + |
| 264 | +const ct = catCrossTab(gender, response); |
| 265 | +console.log("columns:", ct.columns.values); |
| 266 | +console.log("index:", [...ct.index.values]); |
| 267 | +console.log("Yes col:", ct.get("Yes").toArray()); |
| 268 | +console.log("No col:", ct.get("No").toArray()); |
| 269 | + |
| 270 | +// With margins |
| 271 | +const ctm = catCrossTab(gender, response, { margins: true }); |
| 272 | +console.log("\nWith margins:"); |
| 273 | +console.log("columns:", ctm.columns.values); |
| 274 | +console.log("All col:", ctm.get("All").toArray()); |
| 275 | + |
| 276 | +// Normalized |
| 277 | +const ctn = catCrossTab(gender, response, { normalize: true }); |
| 278 | +console.log("\nNormalized Yes col:", ctn.get("Yes").toArray()); |
| 279 | +</textarea> |
| 280 | + <button onclick="run('code-crosstab','out-crosstab')">▶ Run</button> |
| 281 | + <div class="output" id="out-crosstab"></div> |
| 282 | +</div> |
| 283 | + |
| 284 | +<!-- ── catRecode ────────────────────────────────────────────────────── --> |
| 285 | +<div class="card"> |
| 286 | + <h2><code>catRecode(series, mapping)</code></h2> |
| 287 | + <p> |
| 288 | + Rename categories via an object map or a transform function. Unmapped categories |
| 289 | + are left unchanged. |
| 290 | + </p> |
| 291 | + <textarea id="code-recode">const { Series, catRecode } = tsb; |
| 292 | + |
| 293 | +const s = new Series({ data: ["a", "b", "c"] }).cat.setCategories(["a", "b", "c"]); |
| 294 | + |
| 295 | +// Object map (partial) |
| 296 | +const r1 = catRecode(s, { a: "alpha", b: "beta" }); |
| 297 | +console.log("via map:", r1.cat.categories.values); // alpha beta c |
| 298 | +console.log("values:", r1.toArray()); // alpha beta c |
| 299 | + |
| 300 | +// Transform function |
| 301 | +const r2 = catRecode(s, (x) => x.toUpperCase()); |
| 302 | +console.log("via fn:", r2.cat.categories.values); // A B C |
| 303 | +console.log("values:", r2.toArray()); // A B C |
| 304 | +</textarea> |
| 305 | + <button onclick="run('code-recode','out-recode')">▶ Run</button> |
| 306 | + <div class="output" id="out-recode"></div> |
| 307 | +</div> |
| 308 | + |
| 309 | +<script src="playground-runtime.js"></script> |
| 310 | +<script> |
| 311 | +async function run(codeId, outId) { |
| 312 | + const code = document.getElementById(codeId).value; |
| 313 | + const out = document.getElementById(outId); |
| 314 | + out.className = 'output'; |
| 315 | + out.textContent = '…'; |
| 316 | + try { |
| 317 | + const lines = []; |
| 318 | + const tsb = await getTsb(); |
| 319 | + const origLog = console.log; |
| 320 | + console.log = (...args) => lines.push(args.map(v => |
| 321 | + typeof v === 'object' && v !== null ? JSON.stringify(v) : String(v) |
| 322 | + ).join(' ')); |
| 323 | + try { |
| 324 | + const fn = new Function('tsb', code); |
| 325 | + await fn(tsb); |
| 326 | + } finally { |
| 327 | + console.log = origLog; |
| 328 | + } |
| 329 | + out.textContent = lines.join('\n') || '(no output)'; |
| 330 | + out.classList.add('ok'); |
| 331 | + } catch (e) { |
| 332 | + out.textContent = String(e); |
| 333 | + out.classList.add('error'); |
| 334 | + } |
| 335 | +} |
| 336 | +</script> |
| 337 | +</body> |
| 338 | +</html> |
0 commit comments