@glossarist/concept-browser 0.7.83 → 0.7.85
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.
- package/package.json +1 -1
- package/src/components/RelationSphere.vue +213 -55
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glossarist/concept-browser",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.85",
|
|
4
4
|
"description": "Vue SPA for browsing Glossarist terminology datasets with cross-reference resolution, graph visualization, and multi-language support",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -537,11 +537,19 @@ function linkForce() {
|
|
|
537
537
|
if (links.length === 0) return;
|
|
538
538
|
const target = linkDistance;
|
|
539
539
|
const strength = linkStrength;
|
|
540
|
+
/* Read muted sets each tick so toggleType/toggleRegister take effect
|
|
541
|
+
immediately on the next simulation step. */
|
|
542
|
+
const mutedT = mutedTypes.value;
|
|
543
|
+
const mutedR = mutedRegisters.value;
|
|
540
544
|
for (const link of links) {
|
|
545
|
+
if (mutedT.has(link.type)) continue;
|
|
541
546
|
const ai = idToIdx.get(link.source);
|
|
542
547
|
const bi = idToIdx.get(link.target);
|
|
543
548
|
if (ai === undefined || bi === undefined) continue;
|
|
544
549
|
const a = nL[ai], b = nL[bi];
|
|
550
|
+
/* Skip edges involving a muted register (unless the node is the focus) */
|
|
551
|
+
if (a.depth !== 0 && mutedR.has(a.register)) continue;
|
|
552
|
+
if (b.depth !== 0 && mutedR.has(b.register)) continue;
|
|
545
553
|
const dx = b.x - a.x, dy = b.y - a.y, dz = b.z - a.z;
|
|
546
554
|
const d2 = dx*dx + dy*dy + dz*dz;
|
|
547
555
|
if (d2 < 1e-6) continue;
|
|
@@ -630,8 +638,10 @@ function drawEdges(cx: number, cy: number) {
|
|
|
630
638
|
svg.setAttribute('height', String(h));
|
|
631
639
|
svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
|
|
632
640
|
svg.setAttribute('preserveAspectRatio', 'none');
|
|
633
|
-
/* Remove only path + text + rect (edges), keep defs (markers)
|
|
634
|
-
|
|
641
|
+
/* Remove only path + text + rect (edges), keep defs (markers).
|
|
642
|
+
Also remove <line> elements (rake-bundle segments) and <circle>
|
|
643
|
+
(rake junction markers). */
|
|
644
|
+
svg.querySelectorAll('path:not(defs path), line.rake-seg, circle.rake-junction, text.edge-label, rect.edge-label-bg').forEach(el => el.remove());
|
|
635
645
|
if (!svg.querySelector('defs')) ensureMarkers(svg);
|
|
636
646
|
|
|
637
647
|
/* Compute visible node positions */
|
|
@@ -750,21 +760,41 @@ function drawEdges(cx: number, cy: number) {
|
|
|
750
760
|
}
|
|
751
761
|
|
|
752
762
|
/**
|
|
753
|
-
* Render PartitiveRelations as ISO 704 rake
|
|
763
|
+
* Render PartitiveRelations as ISO 704 right-angle rake brackets.
|
|
754
764
|
*
|
|
755
|
-
*
|
|
756
|
-
* so we use a bundle of N curves fanning out from the comprehensive,
|
|
757
|
-
* all sharing visual styling. The bundle communicates "these N edges
|
|
758
|
-
* are ONE relation", not N independent pairwise links.
|
|
765
|
+
* Each relation renders as a true bracket with 90° angles:
|
|
759
766
|
*
|
|
760
|
-
*
|
|
761
|
-
*
|
|
762
|
-
*
|
|
763
|
-
*
|
|
764
|
-
*
|
|
765
|
-
*
|
|
767
|
+
* [comprehensive]
|
|
768
|
+
* │
|
|
769
|
+
* │ ← stem (along stem-axis)
|
|
770
|
+
* │
|
|
771
|
+
* ┌──────────●──────────┐ ← spine (perpendicular to stem-axis; right angle at junction)
|
|
772
|
+
* │ │ │
|
|
773
|
+
* │ │ │ ← drops (parallel to stem-axis; right angle at each tooth)
|
|
774
|
+
* [p1] [p2] [p3]
|
|
766
775
|
*
|
|
767
|
-
*
|
|
776
|
+
* Geometry:
|
|
777
|
+
* 1. stem-axis = direction from comprehensive to centroid of partitives
|
|
778
|
+
* 2. spine-axis = perpendicular to stem-axis
|
|
779
|
+
* 3. spine-center = point along stem-axis at SPINE_FRACTION (60%) from comp
|
|
780
|
+
* 4. For each partitive Pi:
|
|
781
|
+
* - Project Pi onto spine-axis → tooth position
|
|
782
|
+
* - Drop goes from tooth straight to Pi (parallel to stem-axis by construction)
|
|
783
|
+
*
|
|
784
|
+
* All bends are exactly 90° because tooth_i is the foot of perpendicular
|
|
785
|
+
* from Pi to the spine line.
|
|
786
|
+
*
|
|
787
|
+
* ISO 704 line conventions:
|
|
788
|
+
* - default : single solid line per segment
|
|
789
|
+
* - plurality.isShared && !isUncertain : TWO parallel solid lines (close-set)
|
|
790
|
+
* - plurality.isShared && isUncertain : one solid + one dashed line
|
|
791
|
+
* - completeness === 'partial' : spine extends past last tooth
|
|
792
|
+
* (continued backline — "more exist")
|
|
793
|
+
*
|
|
794
|
+
* Per-member certainty 'possible' → that drop is dashed + reduced opacity
|
|
795
|
+
* (stem and spine keep relation style).
|
|
796
|
+
*
|
|
797
|
+
* Diamond marker at the comprehensive end signals the hyperedge origin.
|
|
768
798
|
*/
|
|
769
799
|
function drawRakeBundles(
|
|
770
800
|
svg: SVGSVGElement,
|
|
@@ -772,6 +802,13 @@ function drawRakeBundles(
|
|
|
772
802
|
_cx: number,
|
|
773
803
|
_cy: number,
|
|
774
804
|
) {
|
|
805
|
+
if (mutedTypes.value.has('__partitive__')) return;
|
|
806
|
+
const color = uiStore.isDark ? '#2dd4bf' : '#0d9488';
|
|
807
|
+
const DOUBLE_GAP = 4;
|
|
808
|
+
const DASH = '4 3';
|
|
809
|
+
const SPINE_FRACTION = 0.55; // spine sits 55% from comp toward centroid
|
|
810
|
+
const PARTIAL_TAIL = 28; // px extension for completeness: 'partial'
|
|
811
|
+
|
|
775
812
|
for (const rel of props.partitiveRelations ?? []) {
|
|
776
813
|
const compParsed = UriRouter.parseUri(rel.comprehensive);
|
|
777
814
|
if (!compParsed) continue;
|
|
@@ -791,58 +828,135 @@ function drawRakeBundles(
|
|
|
791
828
|
|
|
792
829
|
if (memberPositions.length === 0) continue;
|
|
793
830
|
|
|
794
|
-
|
|
795
|
-
const
|
|
796
|
-
const
|
|
831
|
+
// 1. Compute stem-axis (comp → centroid of partitives) and spine-axis (perpendicular)
|
|
832
|
+
const centroidX = memberPositions.reduce((s, m) => s + m.pos.x, 0) / memberPositions.length;
|
|
833
|
+
const centroidY = memberPositions.reduce((s, m) => s + m.pos.y, 0) / memberPositions.length;
|
|
834
|
+
const stemDx = centroidX - compPos.x;
|
|
835
|
+
const stemDy = centroidY - compPos.y;
|
|
836
|
+
const stemLen = Math.hypot(stemDx, stemDy) || 1;
|
|
837
|
+
const stemDirX = stemDx / stemLen; // unit vector along stem
|
|
838
|
+
const stemDirY = stemDy / stemLen;
|
|
839
|
+
const spineDirX = -stemDirY; // perpendicular = spine direction
|
|
840
|
+
const spineDirY = stemDirX;
|
|
841
|
+
|
|
842
|
+
// 2. Spine center: along stem-axis at SPINE_FRACTION from comp
|
|
843
|
+
const spineCx = compPos.x + stemDirX * stemLen * SPINE_FRACTION;
|
|
844
|
+
const spineCy = compPos.y + stemDirY * stemLen * SPINE_FRACTION;
|
|
845
|
+
|
|
846
|
+
// 3. Tooth positions = projection of each partitive onto spine line
|
|
847
|
+
const teeth = memberPositions.map(({ member, pos: pPos }) => {
|
|
848
|
+
const vx = pPos.x - spineCx;
|
|
849
|
+
const vy = pPos.y - spineCy;
|
|
850
|
+
const along = vx * spineDirX + vy * spineDirY;
|
|
851
|
+
const toothX = spineCx + spineDirX * along;
|
|
852
|
+
const toothY = spineCy + spineDirY * along;
|
|
853
|
+
return { member, partitivePos: pPos, toothX, toothY, along };
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
// 4. Spine extents (leftmost to rightmost tooth along spine-axis)
|
|
857
|
+
let minAlong = teeth[0].along;
|
|
858
|
+
let maxAlong = teeth[0].along;
|
|
859
|
+
for (const t of teeth) {
|
|
860
|
+
if (t.along < minAlong) minAlong = t.along;
|
|
861
|
+
if (t.along > maxAlong) maxAlong = t.along;
|
|
862
|
+
}
|
|
863
|
+
// For completeness: 'partial', extend past last tooth (continued backline)
|
|
864
|
+
if (rel.completeness === 'partial') maxAlong += PARTIAL_TAIL;
|
|
865
|
+
const spineStartX = spineCx + spineDirX * minAlong;
|
|
866
|
+
const spineStartY = spineCy + spineDirY * minAlong;
|
|
867
|
+
const spineEndX = spineCx + spineDirX * maxAlong;
|
|
868
|
+
const spineEndY = spineCy + spineDirY * maxAlong;
|
|
869
|
+
|
|
797
870
|
const isShared = !!rel.plurality?.isShared;
|
|
798
871
|
const isUncertain = !!rel.plurality?.isUncertain;
|
|
799
|
-
const drawDouble = isShared;
|
|
800
|
-
const secondDashed = isUncertain;
|
|
801
872
|
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
873
|
+
/**
|
|
874
|
+
* Draw a single straight segment with optional parallel offset
|
|
875
|
+
* (used to render the close-set double line) and dash style.
|
|
876
|
+
*/
|
|
877
|
+
const drawSegment = (
|
|
878
|
+
ax: number, ay: number, bx: number, by: number,
|
|
879
|
+
opts: { offset?: number; dashed?: boolean; opacity?: number } = {},
|
|
880
|
+
) => {
|
|
881
|
+
const offset = opts.offset ?? 0;
|
|
882
|
+
const dashed = opts.dashed ?? false;
|
|
883
|
+
const opacity = opts.opacity ?? 0.85;
|
|
884
|
+
const dx = bx - ax, dy = by - ay;
|
|
805
885
|
const len = Math.hypot(dx, dy) || 1;
|
|
806
|
-
|
|
807
|
-
const py = dx / len;
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
path.setAttribute('stroke', color);
|
|
822
|
-
path.setAttribute('stroke-width', '1.5');
|
|
823
|
-
path.setAttribute('opacity', member.certainty === 'possible' ? '0.45' : '0.8');
|
|
824
|
-
path.setAttribute('fill', 'none');
|
|
825
|
-
path.setAttribute('stroke-linecap', 'round');
|
|
826
|
-
if (dashed || member.certainty === 'possible') {
|
|
827
|
-
path.setAttribute('stroke-dasharray', DASH);
|
|
828
|
-
}
|
|
829
|
-
svg.appendChild(path);
|
|
830
|
-
};
|
|
886
|
+
// Perpendicular to THIS segment (not the spine-axis) for parallel offset
|
|
887
|
+
const px = -dy / len, py = dx / len;
|
|
888
|
+
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
|
889
|
+
line.setAttribute('class', 'rake-seg');
|
|
890
|
+
line.setAttribute('x1', String(ax + px * offset));
|
|
891
|
+
line.setAttribute('y1', String(ay + py * offset));
|
|
892
|
+
line.setAttribute('x2', String(bx + px * offset));
|
|
893
|
+
line.setAttribute('y2', String(by + py * offset));
|
|
894
|
+
line.setAttribute('stroke', color);
|
|
895
|
+
line.setAttribute('stroke-width', '1.6');
|
|
896
|
+
line.setAttribute('opacity', String(opacity));
|
|
897
|
+
line.setAttribute('stroke-linecap', 'square'); // sharp corners at right angles
|
|
898
|
+
if (dashed) line.setAttribute('stroke-dasharray', DASH);
|
|
899
|
+
svg.appendChild(line);
|
|
900
|
+
};
|
|
831
901
|
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
902
|
+
/**
|
|
903
|
+
* Draw a segment with the relation's plurality style.
|
|
904
|
+
* Single solid (default), double solid (isShared),
|
|
905
|
+
* or solid + dashed (isShared + isUncertain).
|
|
906
|
+
*/
|
|
907
|
+
const drawSegmentWithPlurality = (
|
|
908
|
+
ax: number, ay: number, bx: number, by: number,
|
|
909
|
+
opts: { opacity?: number; perMemberPossible?: boolean } = {},
|
|
910
|
+
) => {
|
|
911
|
+
const opacity = opts.opacity ?? 0.85;
|
|
912
|
+
if (!isShared) {
|
|
913
|
+
drawSegment(ax, ay, bx, by, { opacity, dashed: opts.perMemberPossible });
|
|
914
|
+
} else {
|
|
915
|
+
drawSegment(ax, ay, bx, by, { offset: -DOUBLE_GAP / 2, opacity });
|
|
916
|
+
drawSegment(ax, ay, bx, by, {
|
|
917
|
+
offset: DOUBLE_GAP / 2,
|
|
918
|
+
opacity,
|
|
919
|
+
dashed: isUncertain || opts.perMemberPossible,
|
|
920
|
+
});
|
|
836
921
|
}
|
|
922
|
+
};
|
|
923
|
+
|
|
924
|
+
// 5. Stem: comp → spine center (along stem-axis)
|
|
925
|
+
drawSegmentWithPlurality(compPos.x, compPos.y, spineCx, spineCy);
|
|
926
|
+
|
|
927
|
+
// 6. Spine: leftmost tooth → rightmost tooth (along spine-axis)
|
|
928
|
+
// (skip if only one tooth — no horizontal extent)
|
|
929
|
+
if (Math.abs(maxAlong - minAlong) > 1) {
|
|
930
|
+
drawSegmentWithPlurality(spineStartX, spineStartY, spineEndX, spineEndY);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// 7. Drops: tooth → partitive (parallel to stem-axis by construction)
|
|
934
|
+
for (const tooth of teeth) {
|
|
935
|
+
drawSegmentWithPlurality(tooth.toothX, tooth.toothY, tooth.partitivePos.x, tooth.partitivePos.y, {
|
|
936
|
+
opacity: tooth.member.certainty === 'possible' ? 0.5 : 0.85,
|
|
937
|
+
perMemberPossible: tooth.member.certainty === 'possible',
|
|
938
|
+
});
|
|
837
939
|
}
|
|
838
940
|
|
|
839
|
-
|
|
941
|
+
// 8. Junction marker at spine center (the right-angle corner)
|
|
942
|
+
const junction = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
|
943
|
+
junction.setAttribute('class', 'rake-junction');
|
|
944
|
+
junction.setAttribute('cx', String(spineCx));
|
|
945
|
+
junction.setAttribute('cy', String(spineCy));
|
|
946
|
+
junction.setAttribute('r', '2.5');
|
|
947
|
+
junction.setAttribute('fill', color);
|
|
948
|
+
junction.setAttribute('opacity', '0.9');
|
|
949
|
+
junction.setAttribute('stroke', uiStore.isDark ? '#0a1f1c' : '#ffffff');
|
|
950
|
+
junction.setAttribute('stroke-width', '1');
|
|
951
|
+
svg.appendChild(junction);
|
|
952
|
+
|
|
953
|
+
// 9. Diamond marker at comprehensive end (hyperedge origin)
|
|
840
954
|
const diamond = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
841
955
|
const dsize = 5;
|
|
842
956
|
diamond.setAttribute('d',
|
|
843
957
|
`M ${compPos.x} ${compPos.y - dsize} L ${compPos.x + dsize} ${compPos.y} L ${compPos.x} ${compPos.y + dsize} L ${compPos.x - dsize} ${compPos.y} Z`);
|
|
844
958
|
diamond.setAttribute('fill', color);
|
|
845
|
-
diamond.setAttribute('opacity', '0.
|
|
959
|
+
diamond.setAttribute('opacity', '0.9');
|
|
846
960
|
diamond.setAttribute('stroke', uiStore.isDark ? '#0a1f1c' : '#ffffff');
|
|
847
961
|
diamond.setAttribute('stroke-width', '1');
|
|
848
962
|
svg.appendChild(diamond);
|
|
@@ -1173,6 +1287,8 @@ watch(() => [props.concept?.id, props.registerId] as const, ([newId, newReg]) =>
|
|
|
1173
1287
|
/* ── Legend — grouped by relation TYPE (not category) ───── */
|
|
1174
1288
|
const legendItems = computed(() => {
|
|
1175
1289
|
graphVersion.value; /* reactive dependency */
|
|
1290
|
+
uiStore.isDark; /* re-evaluate when dark mode toggles (color depends on it) */
|
|
1291
|
+
mutedTypes.value; /* re-evaluate when mute set changes */
|
|
1176
1292
|
const typeMap = new Map<string, { type: string; label: string; color: string; count: number }>();
|
|
1177
1293
|
for (const link of links) {
|
|
1178
1294
|
/* Per-type color (with override) so e.g. 'see' and 'references' differ */
|
|
@@ -1185,6 +1301,18 @@ const legendItems = computed(() => {
|
|
|
1185
1301
|
}
|
|
1186
1302
|
typeMap.get(key)!.count++;
|
|
1187
1303
|
}
|
|
1304
|
+
/* Synthetic entry for PartitiveRelations (hyperedge rake bundles).
|
|
1305
|
+
These don't appear in `links` (they're rendered separately), so we
|
|
1306
|
+
inject a legend entry when any are present. */
|
|
1307
|
+
const partitiveCount = props.partitiveRelations?.length ?? 0;
|
|
1308
|
+
if (partitiveCount > 0) {
|
|
1309
|
+
typeMap.set('__partitive__', {
|
|
1310
|
+
type: '__partitive__',
|
|
1311
|
+
label: 'partitive relation',
|
|
1312
|
+
color: uiStore.isDark ? '#2dd4bf' : '#0d9488',
|
|
1313
|
+
count: partitiveCount,
|
|
1314
|
+
});
|
|
1315
|
+
}
|
|
1188
1316
|
return Array.from(typeMap.values()).sort((a, b) => b.count - a.count);
|
|
1189
1317
|
});
|
|
1190
1318
|
|
|
@@ -1209,6 +1337,9 @@ function toggleRegister(reg: string) {
|
|
|
1209
1337
|
renderDOM();
|
|
1210
1338
|
const c = canvasRef.value;
|
|
1211
1339
|
if (c) drawEdges(c.clientWidth / 2, c.clientHeight * 0.46);
|
|
1340
|
+
/* Re-heat the simulation so nodes "feel" the freed/hidden edges and
|
|
1341
|
+
drift to a new equilibrium instead of staying frozen. */
|
|
1342
|
+
if (sim) sim.alpha(0.5).restart();
|
|
1212
1343
|
}
|
|
1213
1344
|
|
|
1214
1345
|
async function changeDegree(d: 1 | 2 | 3) {
|
|
@@ -1326,6 +1457,10 @@ function toggleType(type: string) {
|
|
|
1326
1457
|
mutedTypes.value = n;
|
|
1327
1458
|
const c = canvasRef.value;
|
|
1328
1459
|
if (c) drawEdges(c.clientWidth / 2, c.clientHeight * 0.46);
|
|
1460
|
+
/* Re-heat the simulation so nodes drift to a new equilibrium with
|
|
1461
|
+
the freed/hidden edges. The link force reads mutedTypes each tick,
|
|
1462
|
+
so toggling effectively "removes" the edge from the force calc. */
|
|
1463
|
+
if (sim) sim.alpha(0.5).restart();
|
|
1329
1464
|
}
|
|
1330
1465
|
|
|
1331
1466
|
function isTypeMuted(type: string): boolean {
|
|
@@ -1498,9 +1633,12 @@ defineExpose({ navigate });
|
|
|
1498
1633
|
<div class="sp-section-label">{{ t('sphere.relationTypes') }}</div>
|
|
1499
1634
|
<div class="sp-legend-grid">
|
|
1500
1635
|
<button v-for="item in legendItems" :key="item.type" class="sp-legend-item"
|
|
1501
|
-
:class="{ muted: isTypeMuted(item.type)
|
|
1636
|
+
:class="{ muted: isTypeMuted(item.type), 'is-partitive': item.type === '__partitive__' }"
|
|
1637
|
+
@click="toggleType(item.type)"
|
|
1502
1638
|
:title="item.type">
|
|
1503
|
-
<span
|
|
1639
|
+
<span v-if="item.type === '__partitive__'" class="sp-swatch sp-swatch-rake"
|
|
1640
|
+
:style="{ '--rake-color': isTypeMuted(item.type) ? '#b8b9cc' : item.color }"></span>
|
|
1641
|
+
<span v-else class="sp-swatch" :style="{ background: isTypeMuted(item.type) ? '#b8b9cc' : item.color }"></span>
|
|
1504
1642
|
<span>{{ item.label }}</span>
|
|
1505
1643
|
<span class="sp-count">{{ item.count }}</span>
|
|
1506
1644
|
</button>
|
|
@@ -1749,6 +1887,26 @@ defineExpose({ navigate });
|
|
|
1749
1887
|
.sp-legend-item:hover { background: var(--rule); }
|
|
1750
1888
|
.sp-legend-item.muted { opacity: 0.35; }
|
|
1751
1889
|
.sp-swatch { width: 18px; height: 2px; flex-shrink: 0; border-radius: 1px; }
|
|
1890
|
+
/* Rake swatch — small bracket icon signaling a PartitiveRelation hyperedge
|
|
1891
|
+
(not a single binary edge). Two parallel solid lines (close-set double)
|
|
1892
|
+
with a perpendicular stem, evoking the ISO 704 rake convention. */
|
|
1893
|
+
.sp-swatch-rake {
|
|
1894
|
+
width: 18px; height: 10px; position: relative; background: transparent !important;
|
|
1895
|
+
border-radius: 0;
|
|
1896
|
+
}
|
|
1897
|
+
.sp-swatch-rake::before,
|
|
1898
|
+
.sp-swatch-rake::after {
|
|
1899
|
+
content: ''; position: absolute; background: var(--rake-color, #0d9488);
|
|
1900
|
+
}
|
|
1901
|
+
.sp-swatch-rake::before {
|
|
1902
|
+
/* horizontal spine (close-set double line) */
|
|
1903
|
+
left: 4px; right: 0; top: 3px; height: 1.5px;
|
|
1904
|
+
box-shadow: 0 3px 0 var(--rake-color, #0d9488);
|
|
1905
|
+
}
|
|
1906
|
+
.sp-swatch-rake::after {
|
|
1907
|
+
/* vertical stem from top of swatch to spine */
|
|
1908
|
+
left: 4px; width: 1.5px; top: 0; height: 4px;
|
|
1909
|
+
}
|
|
1752
1910
|
.sp-count { margin-left: auto; font-family: 'JetBrains Mono', monospace; font-size: 10px; }
|
|
1753
1911
|
|
|
1754
1912
|
.sp-preview {
|