davanstrien HF Staff Claude commited on
Commit
1692046
·
1 Parent(s): cfa6f84

Improve ICONCLASS visualization with Tufte-inspired partial match display

Browse files

- Add hierarchical matching logic to detect partial code overlaps
- Redesign UI with grouped layout (Exact/Partial/No Matches)
- Add visual match depth bars showing percentage of code overlap
- Display matched pairs on same line with clear PREDICTION/GROUND TRUTH labels
- Use typography to highlight matched vs unmatched code portions
- Simplify statistics to clear format (e.g., "2 exact • 1 partial • 2 unmatched")

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>

Files changed (1) hide show
  1. index.html +455 -94
index.html CHANGED
@@ -143,51 +143,131 @@
143
  text-transform: uppercase;
144
  }
145
 
146
- .label {
147
- padding: 3px 0 3px 6px;
148
- margin: 2px 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
  position: relative;
150
- border-left: 2px solid transparent;
151
- font-size: 13px;
152
- line-height: 1.4;
153
  }
154
-
155
- .label:hover {
156
- background: #fafafa;
 
 
 
 
157
  }
158
 
159
- /* Prediction styles */
160
- .prediction {
161
- color: #333;
 
162
  }
163
 
164
- .prediction.invalid {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  color: #999;
166
- text-decoration: line-through;
 
167
  }
168
 
169
- .prediction.match {
170
- border-left-color: #4caf50;
171
- font-weight: 500;
 
172
  }
173
 
174
- /* Ground truth styles */
175
- .ground-truth {
176
- color: #333;
 
 
177
  }
178
 
179
- .ground-truth.matched {
180
- border-left-color: #4caf50;
181
- font-weight: 500;
 
 
182
  }
183
 
184
- /* Match statistics */
185
- .match-stats {
186
- font-size: 11px;
187
  color: #999;
188
- margin-top: 10px;
189
- padding-top: 10px;
190
- border-top: 1px solid #f0f0f0;
191
  }
192
 
193
  .controls {
@@ -263,6 +343,83 @@
263
  let totalRows = null;
264
  let isLoading = false;
265
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
266
  async function loadDatasetPage() {
267
  if (isLoading) return;
268
 
@@ -362,90 +519,294 @@
362
  const predictions = row["iconclass-predictions-parsed"] || [];
363
  const groundTruth = row["iconclass-gt-parsed"] || [];
364
 
365
- // Check for invalid labels (simple heuristic)
366
  const invalidPredictions = predictions.map((pred) => {
367
- // If it says "Not a valid iconclass label" or similar
368
  return (
369
  pred.toLowerCase().includes("not a valid") ||
370
  pred.toLowerCase().includes("invalid")
371
  );
372
  });
373
 
374
- // Find matches
375
- const matches = predictions.filter((pred) =>
376
- groundTruth.some(
377
- (gt) =>
378
- gt.toLowerCase().includes(pred.toLowerCase()) ||
379
- pred.toLowerCase().includes(gt.toLowerCase())
380
- )
381
- );
382
 
383
- // Create comparison view
384
- const comparison = document.createElement("div");
385
- comparison.className = "comparison";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
386
 
387
- // Predictions column
388
- const predColumn = document.createElement("div");
389
- predColumn.className = "column";
390
 
391
- const predTitle = document.createElement("div");
392
- predTitle.className = "column-title";
393
- predTitle.textContent = "Predictions";
394
- predColumn.appendChild(predTitle);
395
 
396
- predictions.forEach((pred, idx) => {
397
- const label = document.createElement("div");
398
- const isInvalid = invalidPredictions[idx];
399
- const isMatch = matches.includes(pred);
400
-
401
- label.className = `label prediction ${isInvalid ? "invalid" : ""} ${
402
- isMatch && !isInvalid ? "match" : ""
403
- }`;
404
- label.textContent = pred;
405
- predColumn.appendChild(label);
406
- });
407
 
408
- // Ground truth column
409
- const gtColumn = document.createElement("div");
410
- gtColumn.className = "column";
411
-
412
- const gtTitle = document.createElement("div");
413
- gtTitle.className = "column-title";
414
- gtTitle.textContent = "Ground Truth";
415
- gtColumn.appendChild(gtTitle);
416
-
417
- groundTruth.forEach((gt) => {
418
- const label = document.createElement("div");
419
- const isMatched = matches.some(
420
- (pred) =>
421
- gt.toLowerCase().includes(pred.toLowerCase()) ||
422
- pred.toLowerCase().includes(gt.toLowerCase())
423
- );
424
- label.className = `label ground-truth ${
425
- isMatched ? "matched" : ""
426
- }`;
427
- label.textContent = gt;
428
- gtColumn.appendChild(label);
429
- });
430
 
431
- comparison.appendChild(predColumn);
432
- comparison.appendChild(gtColumn);
433
- content.appendChild(comparison);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
434
 
435
- // Add match statistics
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
436
  const validPredictions = predictions.filter(
437
  (_, idx) => !invalidPredictions[idx]
438
  );
439
- const matchScore =
440
- validPredictions.length > 0
441
- ? Math.round((matches.length / validPredictions.length) * 100)
442
- : 0;
443
-
444
  const statsDiv = document.createElement("div");
445
  statsDiv.className = "match-stats";
446
- statsDiv.textContent = validPredictions.length > 0
447
- ? `${matches.length}/${validPredictions.length} matches`
448
- : `No valid predictions`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
449
  content.appendChild(statsDiv);
450
 
451
  card.appendChild(content);
 
143
  text-transform: uppercase;
144
  }
145
 
146
+ /* Clean, minimal label styles following Tufte principles */
147
+
148
+ /* Match statistics */
149
+ .match-stats {
150
+ font-size: 11px;
151
+ color: #999;
152
+ margin-top: 10px;
153
+ padding-top: 10px;
154
+ border-top: 1px solid #f0f0f0;
155
+ }
156
+
157
+ /* Tufte-inspired styles for clear match visualization */
158
+ .match-groups {
159
+ margin-top: 12px;
160
+ }
161
+
162
+ .match-group {
163
+ margin-bottom: 20px;
164
+ }
165
+
166
+ .group-header {
167
+ font-size: 10px;
168
+ color: #999;
169
+ text-transform: uppercase;
170
+ letter-spacing: 0.5px;
171
+ margin-bottom: 6px;
172
+ padding-bottom: 3px;
173
+ border-bottom: 1px solid #f0f0f0;
174
+ }
175
+
176
+ .match-pair {
177
+ display: flex;
178
+ align-items: baseline;
179
+ margin: 4px 0;
180
  position: relative;
 
 
 
181
  }
182
+
183
+ .match-connection {
184
+ position: absolute;
185
+ left: 85px;
186
+ width: 1px;
187
+ height: 100%;
188
+ background: #e0e0e0;
189
  }
190
 
191
+ .iconclass-code {
192
+ font-family: "SF Mono", Monaco, "Cascadia Code", monospace;
193
+ font-size: 11px;
194
+ letter-spacing: 0.3px;
195
  }
196
 
197
+ .code-part {
198
+ display: inline-block;
199
+ }
200
+
201
+ .code-matched {
202
+ color: #000;
203
+ font-weight: 600;
204
+ }
205
+
206
+ .code-unmatched {
207
+ color: #ccc;
208
+ }
209
+
210
+ .match-depth-bar {
211
+ display: inline-block;
212
+ width: 40px;
213
+ height: 10px;
214
+ margin: 0 8px;
215
+ background: #f5f5f5;
216
+ position: relative;
217
+ border-radius: 1px;
218
+ }
219
+
220
+ .match-depth-fill {
221
+ position: absolute;
222
+ left: 0;
223
+ top: 0;
224
+ height: 100%;
225
+ background: #666;
226
+ border-radius: 1px;
227
+ }
228
+
229
+ .prediction-side, .gt-side {
230
+ flex: 1;
231
+ display: flex;
232
+ align-items: baseline;
233
+ position: relative;
234
+ }
235
+
236
+ .side-label {
237
+ position: absolute;
238
+ top: -14px;
239
+ left: 0;
240
+ font-size: 9px;
241
  color: #999;
242
+ text-transform: uppercase;
243
+ letter-spacing: 0.5px;
244
  }
245
 
246
+ .code-column {
247
+ width: 80px;
248
+ flex-shrink: 0;
249
+ margin-right: 12px;
250
  }
251
 
252
+ .description-column {
253
+ flex: 1;
254
+ font-size: 12px;
255
+ color: #666;
256
+ line-height: 1.4;
257
  }
258
 
259
+ .unmatched-item {
260
+ display: flex;
261
+ align-items: baseline;
262
+ margin: 3px 0;
263
+ opacity: 0.7;
264
  }
265
 
266
+ .match-symbol {
267
+ font-size: 10px;
 
268
  color: #999;
269
+ margin: 0 6px;
270
+ font-weight: normal;
 
271
  }
272
 
273
  .controls {
 
343
  let totalRows = null;
344
  let isLoading = false;
345
 
346
+ // Extract Iconclass code from a full label (e.g., "71H713 Bathsheba alone" -> "71H713")
347
+ function extractIconclassCode(label) {
348
+ if (!label || label === "Not a valid iconclass label") return null;
349
+ // Match alphanumeric codes, optionally followed by parentheses content
350
+ const match = label.match(/^([A-Z0-9]+(?:\([^)]*\))?)/i);
351
+ return match ? match[1] : null;
352
+ }
353
+
354
+ // Calculate the depth of match between two Iconclass codes
355
+ function calculateMatchDepth(code1, code2) {
356
+ if (!code1 || !code2) return 0;
357
+
358
+ let matchLength = 0;
359
+ const minLength = Math.min(code1.length, code2.length);
360
+
361
+ for (let i = 0; i < minLength; i++) {
362
+ if (code1[i] === code2[i]) {
363
+ matchLength++;
364
+ } else {
365
+ break;
366
+ }
367
+ }
368
+
369
+ return {
370
+ matchLength,
371
+ code1Length: code1.length,
372
+ code2Length: code2.length,
373
+ isExact: code1 === code2,
374
+ isPartial: matchLength > 0 && matchLength < Math.max(code1.length, code2.length),
375
+ matchRatio: matchLength / Math.max(code1.length, code2.length)
376
+ };
377
+ }
378
+
379
+ // Find best matching ground truth for a prediction
380
+ function findBestMatch(predLabel, groundTruthLabels) {
381
+ const predCode = extractIconclassCode(predLabel);
382
+ if (!predCode) return null;
383
+
384
+ let bestMatch = null;
385
+ let bestMatchDepth = 0;
386
+
387
+ for (const gtLabel of groundTruthLabels) {
388
+ const gtCode = extractIconclassCode(gtLabel);
389
+ if (!gtCode) continue;
390
+
391
+ const matchInfo = calculateMatchDepth(predCode, gtCode);
392
+ if (matchInfo.matchLength > bestMatchDepth) {
393
+ bestMatchDepth = matchInfo.matchLength;
394
+ bestMatch = {
395
+ gtLabel,
396
+ gtCode,
397
+ predCode,
398
+ ...matchInfo
399
+ };
400
+ }
401
+ }
402
+
403
+ return bestMatch;
404
+ }
405
+
406
+ // Format code with matched/unmatched portions highlighted
407
+ function formatCodeWithMatch(code, matchLength) {
408
+ if (!code) return '';
409
+ const matched = code.substring(0, matchLength);
410
+ const unmatched = code.substring(matchLength);
411
+ return `<span class="code-matched">${matched}</span><span class="code-unmatched">${unmatched}</span>`;
412
+ }
413
+
414
+ // Get match indicator symbol
415
+ function getMatchSymbol(matchInfo) {
416
+ if (!matchInfo) return '≠';
417
+ if (matchInfo.isExact) return '=';
418
+ if (matchInfo.matchRatio > 0.5) return '≈';
419
+ if (matchInfo.matchRatio > 0) return '∼';
420
+ return '≠';
421
+ }
422
+
423
  async function loadDatasetPage() {
424
  if (isLoading) return;
425
 
 
519
  const predictions = row["iconclass-predictions-parsed"] || [];
520
  const groundTruth = row["iconclass-gt-parsed"] || [];
521
 
522
+ // Check for invalid labels
523
  const invalidPredictions = predictions.map((pred) => {
 
524
  return (
525
  pred.toLowerCase().includes("not a valid") ||
526
  pred.toLowerCase().includes("invalid")
527
  );
528
  });
529
 
530
+ // Build match data structure
531
+ const exactMatches = [];
532
+ const partialMatches = [];
533
+ const unmatchedPredictions = [];
534
+ const unmatchedGroundTruth = new Set(groundTruth);
 
 
 
535
 
536
+ // Find all matches
537
+ predictions.forEach((pred, idx) => {
538
+ if (invalidPredictions[idx]) {
539
+ unmatchedPredictions.push({ prediction: pred, invalid: true });
540
+ } else {
541
+ const bestMatch = findBestMatch(pred, groundTruth);
542
+ if (bestMatch && bestMatch.matchLength > 0) {
543
+ unmatchedGroundTruth.delete(bestMatch.gtLabel);
544
+ if (bestMatch.isExact) {
545
+ exactMatches.push({ prediction: pred, groundTruth: bestMatch.gtLabel, match: bestMatch });
546
+ } else {
547
+ partialMatches.push({ prediction: pred, groundTruth: bestMatch.gtLabel, match: bestMatch });
548
+ }
549
+ } else {
550
+ unmatchedPredictions.push({ prediction: pred });
551
+ }
552
+ }
553
+ });
554
 
555
+ // Sort partial matches by match quality
556
+ partialMatches.sort((a, b) => b.match.matchRatio - a.match.matchRatio);
 
557
 
558
+ // Create new visualization
559
+ const matchGroups = document.createElement("div");
560
+ matchGroups.className = "match-groups";
 
561
 
562
+ // Helper function to extract description
563
+ function getDescription(label) {
564
+ const idx = label.indexOf(' ');
565
+ return idx > -1 ? label.substring(idx + 1) : '';
566
+ }
 
 
 
 
 
 
567
 
568
+ // Helper function to create match depth bar
569
+ function createMatchBar(match) {
570
+ const bar = document.createElement("div");
571
+ bar.className = "match-depth-bar";
572
+ const fill = document.createElement("div");
573
+ fill.className = "match-depth-fill";
574
+ fill.style.width = `${Math.round(match.matchRatio * 100)}%`;
575
+ bar.appendChild(fill);
576
+ return bar;
577
+ }
 
 
 
 
 
 
 
 
 
 
 
 
578
 
579
+ // Exact matches group
580
+ if (exactMatches.length > 0) {
581
+ const group = document.createElement("div");
582
+ group.className = "match-group";
583
+
584
+ const header = document.createElement("div");
585
+ header.className = "group-header";
586
+ header.textContent = `Exact Matches (${exactMatches.length})`;
587
+ group.appendChild(header);
588
+
589
+ // Add labels for first match only
590
+ let isFirst = true;
591
+
592
+ exactMatches.forEach(item => {
593
+ const pair = document.createElement("div");
594
+ pair.className = "match-pair";
595
+ if (isFirst) {
596
+ pair.style.marginTop = "16px"; // Space for labels
597
+ }
598
 
599
+ const predSide = document.createElement("div");
600
+ predSide.className = "prediction-side";
601
+
602
+ if (isFirst) {
603
+ const predLabel = document.createElement("span");
604
+ predLabel.className = "side-label";
605
+ predLabel.textContent = "PREDICTION";
606
+ predSide.appendChild(predLabel);
607
+ }
608
+
609
+ const predCode = document.createElement("span");
610
+ predCode.className = "code-column iconclass-code";
611
+ predCode.innerHTML = `<span class="code-matched">${extractIconclassCode(item.prediction)}</span>`;
612
+
613
+ const predDesc = document.createElement("span");
614
+ predDesc.className = "description-column";
615
+ predDesc.textContent = getDescription(item.prediction);
616
+
617
+ predSide.appendChild(predCode);
618
+ predSide.appendChild(predDesc);
619
+
620
+ const symbol = document.createElement("span");
621
+ symbol.className = "match-symbol";
622
+ symbol.textContent = "=";
623
+
624
+ const gtSide = document.createElement("div");
625
+ gtSide.className = "gt-side";
626
+
627
+ if (isFirst) {
628
+ const gtLabel = document.createElement("span");
629
+ gtLabel.className = "side-label";
630
+ gtLabel.textContent = "GROUND TRUTH";
631
+ gtSide.appendChild(gtLabel);
632
+ }
633
+
634
+ const gtCode = document.createElement("span");
635
+ gtCode.className = "code-column iconclass-code";
636
+ gtCode.innerHTML = `<span class="code-matched">${extractIconclassCode(item.groundTruth)}</span>`;
637
+
638
+ const gtDesc = document.createElement("span");
639
+ gtDesc.className = "description-column";
640
+ gtDesc.textContent = getDescription(item.groundTruth);
641
+
642
+ gtSide.appendChild(gtCode);
643
+ gtSide.appendChild(gtDesc);
644
+
645
+ pair.appendChild(predSide);
646
+ pair.appendChild(symbol);
647
+ pair.appendChild(gtSide);
648
+ group.appendChild(pair);
649
+
650
+ isFirst = false;
651
+ });
652
+
653
+ matchGroups.appendChild(group);
654
+ }
655
+
656
+ // Partial matches group
657
+ if (partialMatches.length > 0) {
658
+ const group = document.createElement("div");
659
+ group.className = "match-group";
660
+
661
+ const header = document.createElement("div");
662
+ header.className = "group-header";
663
+ header.textContent = `Partial Matches (${partialMatches.length})`;
664
+ group.appendChild(header);
665
+
666
+ // Add labels for first match only
667
+ let isFirst = true;
668
+
669
+ partialMatches.forEach(item => {
670
+ const pair = document.createElement("div");
671
+ pair.className = "match-pair";
672
+ if (isFirst) {
673
+ pair.style.marginTop = "16px"; // Space for labels
674
+ }
675
+
676
+ const predSide = document.createElement("div");
677
+ predSide.className = "prediction-side";
678
+
679
+ if (isFirst) {
680
+ const predLabel = document.createElement("span");
681
+ predLabel.className = "side-label";
682
+ predLabel.textContent = "PREDICTION";
683
+ predSide.appendChild(predLabel);
684
+ }
685
+
686
+ const predCode = document.createElement("span");
687
+ predCode.className = "code-column iconclass-code";
688
+ predCode.innerHTML = formatCodeWithMatch(item.match.predCode, item.match.matchLength);
689
+
690
+ const predDesc = document.createElement("span");
691
+ predDesc.className = "description-column";
692
+ predDesc.textContent = getDescription(item.prediction);
693
+
694
+ predSide.appendChild(predCode);
695
+ predSide.appendChild(predDesc);
696
+
697
+ const matchBar = createMatchBar(item.match);
698
+
699
+ const gtSide = document.createElement("div");
700
+ gtSide.className = "gt-side";
701
+
702
+ if (isFirst) {
703
+ const gtLabel = document.createElement("span");
704
+ gtLabel.className = "side-label";
705
+ gtLabel.textContent = "GROUND TRUTH";
706
+ gtSide.appendChild(gtLabel);
707
+ }
708
+
709
+ const gtCode = document.createElement("span");
710
+ gtCode.className = "code-column iconclass-code";
711
+ gtCode.innerHTML = formatCodeWithMatch(item.match.gtCode, item.match.matchLength);
712
+
713
+ const gtDesc = document.createElement("span");
714
+ gtDesc.className = "description-column";
715
+ gtDesc.textContent = getDescription(item.groundTruth);
716
+
717
+ gtSide.appendChild(gtCode);
718
+ gtSide.appendChild(gtDesc);
719
+
720
+ pair.appendChild(predSide);
721
+ pair.appendChild(matchBar);
722
+ pair.appendChild(gtSide);
723
+ group.appendChild(pair);
724
+
725
+ isFirst = false;
726
+ });
727
+
728
+ matchGroups.appendChild(group);
729
+ }
730
+
731
+ // Unmatched items group
732
+ if (unmatchedPredictions.length > 0 || unmatchedGroundTruth.size > 0) {
733
+ const group = document.createElement("div");
734
+ group.className = "match-group";
735
+
736
+ const header = document.createElement("div");
737
+ header.className = "group-header";
738
+ header.textContent = `No Matches`;
739
+ group.appendChild(header);
740
+
741
+ // Unmatched predictions
742
+ unmatchedPredictions.forEach(item => {
743
+ const div = document.createElement("div");
744
+ div.className = "unmatched-item";
745
+
746
+ const label = document.createElement("span");
747
+ label.style.marginRight = "20px";
748
+ label.innerHTML = `<span class="iconclass-code" style="color: #999">P:</span> `;
749
+
750
+ const code = extractIconclassCode(item.prediction);
751
+ if (code) {
752
+ label.innerHTML += `<span class="iconclass-code code-unmatched">${code}</span> `;
753
+ }
754
+ label.innerHTML += `<span class="description-column">${getDescription(item.prediction)}</span>`;
755
+
756
+ div.appendChild(label);
757
+ group.appendChild(div);
758
+ });
759
+
760
+ // Unmatched ground truth
761
+ unmatchedGroundTruth.forEach(gt => {
762
+ const div = document.createElement("div");
763
+ div.className = "unmatched-item";
764
+
765
+ const label = document.createElement("span");
766
+ label.innerHTML = `<span class="iconclass-code" style="color: #999">G:</span> `;
767
+
768
+ const code = extractIconclassCode(gt);
769
+ if (code) {
770
+ label.innerHTML += `<span class="iconclass-code" style="color: #666">${code}</span> `;
771
+ }
772
+ label.innerHTML += `<span class="description-column">${getDescription(gt)}</span>`;
773
+
774
+ div.appendChild(label);
775
+ group.appendChild(div);
776
+ });
777
+
778
+ matchGroups.appendChild(group);
779
+ }
780
+
781
+ content.appendChild(matchGroups);
782
+
783
+ // Add compact match statistics
784
  const validPredictions = predictions.filter(
785
  (_, idx) => !invalidPredictions[idx]
786
  );
787
+
 
 
 
 
788
  const statsDiv = document.createElement("div");
789
  statsDiv.className = "match-stats";
790
+
791
+ if (validPredictions.length > 0) {
792
+ const totalMatches = exactMatches.length + partialMatches.length;
793
+ const statsParts = [];
794
+
795
+ if (exactMatches.length > 0) {
796
+ statsParts.push(`${exactMatches.length} exact`);
797
+ }
798
+ if (partialMatches.length > 0) {
799
+ statsParts.push(`${partialMatches.length} partial`);
800
+ }
801
+ if (unmatchedPredictions.length > 0) {
802
+ statsParts.push(`${unmatchedPredictions.length} unmatched`);
803
+ }
804
+
805
+ statsDiv.textContent = statsParts.join(' • ');
806
+ } else {
807
+ statsDiv.textContent = 'No valid predictions';
808
+ }
809
+
810
  content.appendChild(statsDiv);
811
 
812
  card.appendChild(content);