Spaces:
Running
Running
| <script lang="ts"> | |
| import { | |
| fetchAllRows, | |
| fetchDatasetConfigYaml, | |
| type DatasetConfigYaml, | |
| } from "./huggingfaceApi"; | |
| import type { | |
| TermDefinition, | |
| EquivalencyScore, | |
| GradingTemplateEntry, | |
| } from "./types"; | |
| import { marked } from "marked"; | |
| import { config, getDatasetName } from "./config"; | |
| // Configuration from centralized config | |
| const availableJurisdictions = config.jurisdictions; | |
| const dataset = getDatasetName("structured-answers"); | |
| const scoresDataset = getDatasetName("equivalency-scores"); | |
| const gradingTemplatesDataset = getDatasetName("grading-templates"); | |
| const directTranslations = config.directTranslations; | |
| const equivalencyScoreLabels = config.equivalencyScoreLabels; | |
| // Create bidirectional lookup map (both English->Swedish and Swedish->English) | |
| const translationMap = new Map<string, string>(); | |
| Object.entries(directTranslations).forEach(([en, sv]) => { | |
| translationMap.set(en.toLowerCase(), sv.toLowerCase()); | |
| translationMap.set(sv.toLowerCase(), en.toLowerCase()); | |
| }); | |
| // Form inputs | |
| let jurisdiction1 = $state<string>(config.defaults.jurisdiction1); | |
| let jurisdiction2 = $state<string>(config.defaults.jurisdiction2); | |
| // Data state | |
| let data1 = $state<TermDefinition[]>([]); | |
| let data2 = $state<TermDefinition[]>([]); | |
| let equivalencyScores = $state<EquivalencyScore[]>([]); | |
| let selectedTerm1 = $state<string | null>(null); | |
| let selectedTerm2 = $state<string | null>(null); | |
| let datasetConfig = $state<DatasetConfigYaml | null>(null); | |
| let gradingTemplates_j1 = $state<GradingTemplateEntry[]>([]); | |
| let gradingTemplates_j2 = $state<GradingTemplateEntry[]>([]); | |
| // Track previous selected terms for detecting user-initiated changes (loop prevention) | |
| let prevSelectedTerm1 = $state<string | null>(null); | |
| let prevSelectedTerm2 = $state<string | null>(null); | |
| // UI state | |
| let loading = $state(false); | |
| let error = $state(""); | |
| let hasLoaded = $state(false); | |
| let loadingDetails = $state(false); | |
| let selectedCategory = $state<string>("All"); | |
| // Track previous values to detect changes | |
| let prevJurisdiction1 = $state<string>(config.defaults.jurisdiction1); | |
| let prevJurisdiction2 = $state<string>(config.defaults.jurisdiction2); | |
| // Reload when jurisdictions change | |
| $effect(() => { | |
| const j1 = jurisdiction1; | |
| const j2 = jurisdiction2; | |
| if (hasLoaded && (j1 !== prevJurisdiction1 || j2 !== prevJurisdiction2)) { | |
| prevJurisdiction1 = j1; | |
| prevJurisdiction2 = j2; | |
| loadComparison(); | |
| } | |
| }); | |
| async function loadComparison() { | |
| if (jurisdiction1 === jurisdiction2) { | |
| error = "Please select two different jurisdictions"; | |
| return; | |
| } | |
| loading = true; | |
| error = ""; | |
| data1 = []; | |
| data2 = []; | |
| equivalencyScores = []; | |
| selectedTerm1 = null; | |
| selectedTerm2 = null; | |
| prevSelectedTerm1 = null; | |
| prevSelectedTerm2 = null; | |
| hasLoaded = false; | |
| // Start loading grading templates/details concurrently to reduce wait time | |
| const detailsPromise = loadDetails(); | |
| try { | |
| // Determine the config name for equivalency scores (alphabetically sorted) | |
| const sortedJurisdictions = [jurisdiction1, jurisdiction2].sort(); | |
| const scoresConfig = `${sortedJurisdictions[0]}_${sortedJurisdictions[1]}`; | |
| // Fetch both jurisdictions and equivalency scores in parallel | |
| const [rows1, rows2, scoresRows] = await Promise.all([ | |
| fetchAllRows(dataset, jurisdiction1, "train", undefined), | |
| fetchAllRows(dataset, jurisdiction2, "train", undefined), | |
| fetchAllRows(scoresDataset, scoresConfig, "train", undefined), | |
| ]); | |
| data1 = (rows1 as TermDefinition[]).sort((a, b) => | |
| a.term.localeCompare(b.term) | |
| ); | |
| data2 = (rows2 as TermDefinition[]).sort((a, b) => | |
| a.term.localeCompare(b.term) | |
| ); | |
| equivalencyScores = scoresRows as EquivalencyScore[]; | |
| hasLoaded = true; | |
| // Fetch config.yaml for this jurisdiction pair | |
| try { | |
| const configPath = `${jurisdiction1}_${jurisdiction2}/config.yaml`; | |
| datasetConfig = await fetchDatasetConfigYaml( | |
| scoresDataset, | |
| configPath, | |
| "main", | |
| undefined | |
| ); | |
| } catch (e) { | |
| datasetConfig = null; | |
| console.warn("Could not load dataset config.yaml", e); | |
| } | |
| // Auto-select first term from jurisdiction 1 and its equivalent in jurisdiction 2 | |
| if (data1.length > 0) { | |
| const firstTerm = data1[0].term; | |
| selectedTerm1 = firstTerm; | |
| prevSelectedTerm1 = firstTerm; | |
| // Try to find direct translation equivalent in jurisdiction 2 | |
| if (data2.length > 0) { | |
| const translation = getDirectTranslation(firstTerm); | |
| const matchingTerm = translation | |
| ? findTermInData(translation, data2) | |
| : null; | |
| if (matchingTerm) { | |
| selectedTerm2 = matchingTerm; | |
| prevSelectedTerm2 = matchingTerm; | |
| } else { | |
| selectedTerm2 = data2[0].term; | |
| prevSelectedTerm2 = data2[0].term; | |
| } | |
| } | |
| } else if (data2.length > 0) { | |
| selectedTerm2 = data2[0].term; | |
| prevSelectedTerm2 = data2[0].term; | |
| } | |
| } catch (e) { | |
| error = e instanceof Error ? e.message : "Failed to load jurisdictions"; | |
| console.error("Jurisdiction loading error:", e); | |
| } finally { | |
| loading = false; | |
| // Ensure details loading finishes (any error already handled in loadDetails) | |
| try { | |
| await detailsPromise; | |
| } catch (e) { | |
| console.warn("loadDetails failed:", e); | |
| } | |
| } | |
| } | |
| async function loadDetails() { | |
| loadingDetails = true; | |
| try { | |
| const [rows_j1, rows_j2] = await Promise.all([ | |
| fetchAllRows( | |
| gradingTemplatesDataset, | |
| jurisdiction1, | |
| "train", | |
| undefined | |
| ), | |
| fetchAllRows( | |
| gradingTemplatesDataset, | |
| jurisdiction2, | |
| "train", | |
| undefined | |
| ), | |
| ]); | |
| gradingTemplates_j1 = rows_j1 as GradingTemplateEntry[]; | |
| gradingTemplates_j2 = rows_j2 as GradingTemplateEntry[]; | |
| } catch (e) { | |
| // Non-fatal: templates may not exist for all jurisdictions | |
| gradingTemplates_j1 = []; | |
| gradingTemplates_j2 = []; | |
| console.warn("Failed to load grading templates:", e); | |
| } finally { | |
| loadingDetails = false; | |
| } | |
| } | |
| function getSelectedDefinition1(): string { | |
| if (!selectedTerm1) return ""; | |
| const found = data1.find((item) => item.term === selectedTerm1); | |
| return found?.definition || ""; | |
| } | |
| function getSelectedDefinition2(): string { | |
| if (!selectedTerm2) return ""; | |
| const found = data2.find((item) => item.term === selectedTerm2); | |
| return found?.definition || ""; | |
| } | |
| function normalizeTermForComparison(term: string | undefined): string { | |
| if (term === undefined) return ""; | |
| // Replace underscores with spaces and normalize whitespace for comparison | |
| return term.replace(/_/g, " ").replace(/\s+/g, " ").trim().toLowerCase(); | |
| } | |
| function formatCategoryName(name: string): string { | |
| // Replace underscores with spaces for display | |
| return name.replace(/_/g, " "); | |
| } | |
| // Get direct translation equivalent for a term | |
| function getDirectTranslation(term: string): string | null { | |
| const normalized = normalizeTermForComparison(term); | |
| return translationMap.get(normalized) || null; | |
| } | |
| // Find a term in a data array by its normalized form | |
| function findTermInData( | |
| normalizedTarget: string, | |
| data: TermDefinition[] | |
| ): string | null { | |
| const found = data.find( | |
| (item) => normalizeTermForComparison(item.term) === normalizedTarget | |
| ); | |
| return found?.term || null; | |
| } | |
| // Auto-select direct translation when selectedTerm1 changes (user-initiated) | |
| $effect(() => { | |
| const current = selectedTerm1; | |
| if (current && current !== prevSelectedTerm1 && data2.length > 0) { | |
| const translation = getDirectTranslation(current); | |
| if (translation) { | |
| const matchingTerm = findTermInData(translation, data2); | |
| if (matchingTerm && matchingTerm !== selectedTerm2) { | |
| selectedTerm2 = matchingTerm; | |
| prevSelectedTerm2 = matchingTerm; | |
| } | |
| } | |
| prevSelectedTerm1 = current; | |
| } | |
| }); | |
| // Auto-select direct translation when selectedTerm2 changes (user-initiated) | |
| $effect(() => { | |
| const current = selectedTerm2; | |
| if (current && current !== prevSelectedTerm2 && data1.length > 0) { | |
| const translation = getDirectTranslation(current); | |
| if (translation) { | |
| const matchingTerm = findTermInData(translation, data1); | |
| if (matchingTerm && matchingTerm !== selectedTerm1) { | |
| selectedTerm1 = matchingTerm; | |
| prevSelectedTerm1 = matchingTerm; | |
| } | |
| } | |
| prevSelectedTerm2 = current; | |
| } | |
| }); | |
| // Check if a term should be highlighted based on the selected term in the other jurisdiction | |
| function shouldHighlightTerm( | |
| term: string, | |
| selectedInOtherJurisdiction: string | null | |
| ): boolean { | |
| if (!selectedInOtherJurisdiction) return false; | |
| const equivalentOfSelected = getDirectTranslation( | |
| selectedInOtherJurisdiction | |
| ); | |
| if (!equivalentOfSelected) return false; | |
| const normalizedTerm = normalizeTermForComparison(term); | |
| return normalizedTerm === equivalentOfSelected; | |
| } | |
| function getSimilarityScore(): EquivalencyScore | null { | |
| if (!selectedTerm1 || !selectedTerm2 || equivalencyScores.length === 0) { | |
| return null; | |
| } | |
| // Normalize selected terms for comparison | |
| const normalizedTerm1 = normalizeTermForComparison(selectedTerm1); | |
| const normalizedTerm2 = normalizeTermForComparison(selectedTerm2); | |
| // Look for the score in both directions, comparing normalized terms | |
| const score = equivalencyScores.find((s) => { | |
| const normalizedJ1 = normalizeTermForComparison(s.term_j1); | |
| const normalizedJ2 = normalizeTermForComparison(s.term_j2); | |
| return ( | |
| (normalizedJ1 === normalizedTerm1 && | |
| normalizedJ2 === normalizedTerm2) || | |
| (normalizedJ1 === normalizedTerm2 && normalizedJ2 === normalizedTerm1) | |
| ); | |
| }); | |
| return score || null; | |
| } | |
| // Get unique categories from comparisons | |
| function getUniqueCategories(score: EquivalencyScore | null): string[] { | |
| if (!score || !score.comparisons) return []; | |
| const categories = new Set(score.comparisons.map((c) => c.category)); | |
| return Array.from(categories).sort(); | |
| } | |
| // Get label for equivalency score based on configured ranges | |
| function getScoreLabel(scoreValue: number): string { | |
| const match = equivalencyScoreLabels.find( | |
| (range) => scoreValue >= range.min && scoreValue < range.max | |
| ); | |
| // Handle edge case for max score (5.0) | |
| if (!match && scoreValue >= 5.0) { | |
| return equivalencyScoreLabels[equivalencyScoreLabels.length - 1].label; | |
| } | |
| return match?.label || ""; | |
| } | |
| // Return a CSS class name for coloring scores (low/medium/high) | |
| function getScoreClass(scoreValue: number): string { | |
| // More fine-grained buckets across the 1.0-5.0 range | |
| // 1.0 - 1.99 -> very-low | |
| // 2.0 - 2.49 -> low | |
| // 2.5 - 2.99 -> medium-low | |
| // 3.0 - 3.49 -> medium | |
| // 3.5 - 3.99 -> medium-high | |
| // 4.0 - 4.49 -> high | |
| // 4.5 - 5.0+ -> very-high | |
| if (scoreValue >= 4.5) return "score-very-high"; | |
| if (scoreValue >= 4.0) return "score-high"; | |
| if (scoreValue >= 3.5) return "score-medium-high"; | |
| if (scoreValue >= 3.0) return "score-medium"; | |
| if (scoreValue >= 2.5) return "score-medium-low"; | |
| if (scoreValue >= 2.0) return "score-low"; | |
| return "score-very-low"; | |
| } | |
| // Filter comparisons by selected category | |
| function getFilteredComparisons(score: EquivalencyScore | null) { | |
| if (!score || !score.comparisons) return []; | |
| if (selectedCategory === "All") return score.comparisons; | |
| return score.comparisons.filter((c) => c.category === selectedCategory); | |
| } | |
| // Find a grading template entry matching a comparison's category/subcategory and optional term | |
| function findGradingTemplate( | |
| category: string, | |
| subcategory: string | undefined, | |
| templates: GradingTemplateEntry[], | |
| term?: string | |
| ): GradingTemplateEntry | null { | |
| if (!templates || templates.length === 0) return null; | |
| const normalizedTarget = term ? normalizeTermForComparison(term) : null; | |
| return ( | |
| templates.find((t) => { | |
| const catMatch = | |
| t.category === category && | |
| (t.subcategory || "") === (subcategory || ""); | |
| if (!catMatch) return false; | |
| if (!normalizedTarget) return true; | |
| return normalizeTermForComparison(t.term) === normalizedTarget; | |
| }) || null | |
| ); | |
| } | |
| // Helper getters for templates + question text to avoid {@const} in template | |
| function getTemplateJ1(comparison: { | |
| category: string; | |
| subcategory?: string; | |
| term_j1?: string; | |
| }) { | |
| return findGradingTemplate( | |
| comparison.category, | |
| comparison.subcategory, | |
| gradingTemplates_j1, | |
| comparison.term_j1 | |
| ); | |
| } | |
| function getTemplateJ2(comparison: { | |
| category: string; | |
| subcategory?: string; | |
| term_j2?: string; | |
| }) { | |
| return findGradingTemplate( | |
| comparison.category, | |
| comparison.subcategory, | |
| gradingTemplates_j2, | |
| comparison.term_j2 | |
| ); | |
| } | |
| function getQuestionText(comparison: { | |
| category: string; | |
| subcategory?: string; | |
| term_j1?: string; | |
| term_j2?: string; | |
| }) { | |
| return ( | |
| getTemplateJ1(comparison)?.question || | |
| getTemplateJ2(comparison)?.question || | |
| "" | |
| ); | |
| } | |
| </script> | |
| <div class="jurisdiction-comparison"> | |
| <h2>Jurisdiction Term Comparison</h2> | |
| <!-- Model Info Display --> | |
| {#if datasetConfig} | |
| <div class="dataset-model-info subtle"> | |
| <span class="info-tooltip-inline"> | |
| <strong>Scoring Model:</strong> | |
| {datasetConfig.scoring_model} | |
| <span class="tooltip-content-inline"> | |
| <strong>Scoring Model</strong> is the model used to compute the equivalency | |
| score between terms. It uses a language model to assess how closely terms | |
| from different jurisdictions match in meaning. | |
| </span> | |
| </span> | |
| <span class="sep">|</span> | |
| <span class="info-tooltip-inline"> | |
| <strong>Synthesis Model:</strong> | |
| {datasetConfig.synthesis_model} | |
| <span class="tooltip-content-inline"> | |
| <strong>Synthesis Model</strong> is the model used to synthesize comparative | |
| law notes. Generative AI is used to assess and explain the similarities | |
| and differences between terms from different jurisdictions. | |
| </span> | |
| </span> | |
| <span class="sep">|</span> | |
| <span> | |
| <strong>Last Updated:</strong> | |
| {datasetConfig.generation_date || "N/A"} | |
| </span> | |
| </div> | |
| {/if} | |
| <div class="form"> | |
| <div class="form-row"> | |
| <div class="form-group"> | |
| <label for="jurisdiction1">Jurisdiction 1:</label> | |
| <select | |
| id="jurisdiction1" | |
| bind:value={jurisdiction1} | |
| disabled={loading} | |
| > | |
| {#each availableJurisdictions as jur} | |
| <option value={jur.code}>{jur.label}</option> | |
| {/each} | |
| </select> | |
| </div> | |
| <div class="form-group"> | |
| <label for="jurisdiction2">Jurisdiction 2:</label> | |
| <select | |
| id="jurisdiction2" | |
| bind:value={jurisdiction2} | |
| disabled={loading} | |
| > | |
| {#each availableJurisdictions as jur} | |
| <option value={jur.code}>{jur.label}</option> | |
| {/each} | |
| </select> | |
| </div> | |
| </div> | |
| {#if !hasLoaded} | |
| <button onclick={loadComparison} disabled={loading}> | |
| {loading ? "Loading..." : "Load Comparison"} | |
| </button> | |
| {/if} | |
| </div> | |
| {#if error} | |
| <div class="error"> | |
| <strong>Error:</strong> | |
| {error} | |
| </div> | |
| {/if} | |
| {#if hasLoaded} | |
| <div class="comparison-container"> | |
| <!-- Jurisdiction 1 --> | |
| <div class="jurisdiction-column"> | |
| <h3> | |
| {availableJurisdictions.find((j) => j.code === jurisdiction1)?.label} | |
| </h3> | |
| <div class="term-selector"> | |
| <label for="term1">Select Term:</label> | |
| <select id="term1" bind:value={selectedTerm1}> | |
| {#each data1 as item} | |
| <option value={item.term}> | |
| {shouldHighlightTerm(item.term, selectedTerm2) | |
| ? "★ " | |
| : ""}{item.term} | |
| </option> | |
| {/each} | |
| </select> | |
| <small class="translation-hint" | |
| >★ = Direct translation of the term selected in the opposite | |
| jurisdiction</small | |
| > | |
| </div> | |
| {#if selectedTerm1} | |
| <div class="definition-box"> | |
| <h4>Definition:</h4> | |
| <div class="markdown-content"> | |
| {@html marked(getSelectedDefinition1())} | |
| </div> | |
| </div> | |
| {/if} | |
| <div class="info"> | |
| <small>{data1.length} terms available</small> | |
| </div> | |
| </div> | |
| <!-- Jurisdiction 2 --> | |
| <div class="jurisdiction-column"> | |
| <h3> | |
| {availableJurisdictions.find((j) => j.code === jurisdiction2)?.label} | |
| </h3> | |
| <div class="term-selector"> | |
| <label for="term2">Select Term:</label> | |
| <select id="term2" bind:value={selectedTerm2}> | |
| {#each data2 as item} | |
| <option value={item.term}> | |
| {shouldHighlightTerm(item.term, selectedTerm1) | |
| ? "★ " | |
| : ""}{item.term} | |
| </option> | |
| {/each} | |
| </select> | |
| <small class="translation-hint" | |
| >★ = Direct translation of the term selected in the opposite | |
| jurisdiction</small | |
| > | |
| </div> | |
| {#if selectedTerm2} | |
| <div class="definition-box"> | |
| <h4>Definition:</h4> | |
| <div class="markdown-content"> | |
| {@html marked(getSelectedDefinition2())} | |
| </div> | |
| </div> | |
| {/if} | |
| <div class="info"> | |
| <small>{data2.length} terms available</small> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Similarity Score Display --> | |
| {#if selectedTerm1 && selectedTerm2} | |
| {@const score = getSimilarityScore()} | |
| {#if score} | |
| <div class="similarity-score"> | |
| <h3>Equivalency Score</h3> | |
| <div class="score-display"> | |
| <span class="score-label"> | |
| {getScoreLabel(score.aggregated_similarity_score)} | |
| <!-- Info icon with tooltip --> | |
| <span class="info-tooltip"> | |
| <svg | |
| class="info-icon" | |
| xmlns="http://www.w3.org/2000/svg" | |
| width="16" | |
| height="16" | |
| viewBox="0 0 24 24" | |
| fill="none" | |
| stroke="currentColor" | |
| stroke-width="2" | |
| stroke-linecap="round" | |
| stroke-linejoin="round" | |
| > | |
| <circle cx="12" cy="12" r="10"></circle> | |
| <line x1="12" y1="16" x2="12" y2="12"></line> | |
| <line x1="12" y1="8" x2="12.01" y2="8"></line> | |
| </svg> | |
| <span class="tooltip-content"> | |
| <strong>Score Legend (1 – 5)</strong> | |
| {#each equivalencyScoreLabels as range} | |
| <span class="tooltip-row"> | |
| <span class="tooltip-range" | |
| >{range.min.toFixed(2)}–{range.max.toFixed(2)}</span | |
| > | |
| <span>{range.label}</span> | |
| </span> | |
| {/each} | |
| </span> | |
| </span> | |
| </span> | |
| <strong class={getScoreClass(score.aggregated_similarity_score)} | |
| >{score.aggregated_similarity_score.toFixed(2)}</strong | |
| > | |
| </div> | |
| {#if score.comparative_law_note} | |
| <div class="comparative-note"> | |
| <div class="markdown-content"> | |
| {@html marked(score.comparative_law_note)} | |
| </div> | |
| </div> | |
| {/if} | |
| {#if score.comparisons && score.comparisons.length > 0} | |
| {@const categories = getUniqueCategories(score)} | |
| {@const filteredComparisons = getFilteredComparisons(score)} | |
| <div class="comparisons-section"> | |
| <h3>Detailed Comparisons</h3> | |
| {#if loadingDetails} | |
| <div class="details-loading"> | |
| <div class="spinner" aria-hidden="true"></div> | |
| <div class="spinner-label">Loading details…</div> | |
| </div> | |
| {:else} | |
| <!-- Category Filter Pills --> | |
| {#if categories.length > 0} | |
| <div class="category-filters"> | |
| <button | |
| class="filter-pill" | |
| class:active={selectedCategory === "All"} | |
| onclick={() => (selectedCategory = "All")} | |
| > | |
| All ({score.comparisons.length}) | |
| </button> | |
| {#each categories as category} | |
| {@const count = score.comparisons.filter( | |
| (c) => c.category === category | |
| ).length} | |
| <button | |
| class="filter-pill" | |
| class:active={selectedCategory === category} | |
| onclick={() => (selectedCategory = category)} | |
| > | |
| {formatCategoryName(category)} ({count}) | |
| </button> | |
| {/each} | |
| </div> | |
| {/if} | |
| <div class="comparisons-list"> | |
| {#if selectedTerm1 || selectedTerm2} | |
| <div class="comparisons-header"> | |
| <div class="header-column left"> | |
| <div class="detail-term"> | |
| {normalizeTermForComparison(selectedTerm1)} | |
| </div> | |
| </div> | |
| <div class="header-column center"> | |
| <div class="detail-term">Comparison</div> | |
| </div> | |
| <div class="header-column right"> | |
| <div class="detail-term"> | |
| {normalizeTermForComparison(selectedTerm2)} | |
| </div> | |
| </div> | |
| </div> | |
| {/if} | |
| {#each filteredComparisons as comparison} | |
| <div class="comparison-item"> | |
| <div class="comparison-header"> | |
| <span class="comparison-category"> | |
| {formatCategoryName(comparison.category)} | |
| </span> | |
| {#if comparison.subcategory} | |
| <span class="comparison-subcategory"> | |
| › {formatCategoryName(comparison.subcategory)} | |
| </span> | |
| {/if} | |
| <div class="category-meta"> | |
| <span class="category-weight" | |
| >Weight: {comparison.weight.toFixed(2)}</span | |
| > | |
| <span class="category-weighted" | |
| >Weighted Score: {comparison.weighted_similarity_score.toFixed( | |
| 2 | |
| )}</span | |
| > | |
| </div> | |
| <span | |
| class={"comparison-score " + | |
| getScoreClass(comparison.similarity_score)} | |
| >{comparison.similarity_score.toFixed(2)}</span | |
| > | |
| </div> | |
| {#if getQuestionText(comparison)} | |
| <div class="detail-question-single"> | |
| {getQuestionText(comparison)} | |
| </div> | |
| {/if} | |
| <div class="comparison-details"> | |
| <div class="detail-column left"> | |
| {#if getTemplateJ1(comparison)} | |
| <div class="detail-answer"> | |
| {getTemplateJ1(comparison)?.answer} | |
| </div> | |
| {:else} | |
| <div class="detail-empty">—</div> | |
| {/if} | |
| </div> | |
| <div class="detail-column center"> | |
| {#if comparison.reasoning} | |
| <div class="comparison-reasoning"> | |
| {comparison.reasoning} | |
| </div> | |
| {/if} | |
| </div> | |
| <div class="detail-column right"> | |
| {#if getTemplateJ2(comparison)} | |
| <div class="detail-answer"> | |
| {getTemplateJ2(comparison)?.answer} | |
| </div> | |
| {:else} | |
| <div class="detail-empty">—</div> | |
| {/if} | |
| </div> | |
| </div> | |
| </div> | |
| {/each} | |
| </div> | |
| {/if} | |
| </div> | |
| {/if} | |
| </div> | |
| {:else} | |
| <div class="similarity-score no-score"> | |
| <p>No equivalency score found for this term pair.</p> | |
| </div> | |
| {/if} | |
| {/if} | |
| {/if} | |
| </div> | |
| <style> | |
| .jurisdiction-comparison { | |
| max-width: 75%; | |
| margin: 0 auto; | |
| padding: 2rem; | |
| color: #333; | |
| } | |
| h2 { | |
| margin-bottom: 1.5rem; | |
| color: #333; | |
| text-align: center; | |
| background: none; | |
| font-weight: 700; | |
| letter-spacing: 0.02em; | |
| } | |
| @media (prefers-color-scheme: dark) { | |
| h2 { | |
| color: #f7f7f7; | |
| } | |
| } | |
| h3 { | |
| margin: 0 0 1rem 0; | |
| color: #444; | |
| font-size: 1.25rem; | |
| } | |
| h4 { | |
| margin: 0 0 0.5rem 0; | |
| color: #555; | |
| font-size: 1rem; | |
| font-weight: 600; | |
| } | |
| .form { | |
| background: #f5f5f5; | |
| padding: 1.5rem; | |
| border-radius: 8px; | |
| margin-bottom: 2rem; | |
| color: #333; | |
| } | |
| .form-group { | |
| margin-bottom: 1rem; | |
| } | |
| .form-row { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 1rem; | |
| } | |
| label { | |
| display: block; | |
| margin-bottom: 0.5rem; | |
| font-weight: 500; | |
| color: #555; | |
| } | |
| select { | |
| width: 100%; | |
| padding: 0.5rem; | |
| border: 1px solid #ddd; | |
| border-radius: 4px; | |
| font-size: 1rem; | |
| box-sizing: border-box; | |
| background: white; | |
| color: #333; | |
| } | |
| select:disabled { | |
| background: #e9e9e9; | |
| cursor: not-allowed; | |
| } | |
| button { | |
| background: #646cff; | |
| color: white; | |
| padding: 0.75rem 1.5rem; | |
| border: none; | |
| border-radius: 4px; | |
| font-size: 1rem; | |
| cursor: pointer; | |
| transition: background 0.2s; | |
| width: 100%; | |
| } | |
| button:hover:not(:disabled) { | |
| background: #535bf2; | |
| } | |
| button:disabled { | |
| background: #ccc; | |
| cursor: not-allowed; | |
| } | |
| .error { | |
| background: #fee; | |
| color: #c33; | |
| padding: 1rem; | |
| border-radius: 4px; | |
| margin-bottom: 1rem; | |
| border: 1px solid #fcc; | |
| } | |
| .comparison-container { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 2rem; | |
| margin-top: 2rem; | |
| } | |
| .jurisdiction-column { | |
| background: white; | |
| padding: 1.5rem; | |
| border-radius: 8px; | |
| border: 1px solid #ddd; | |
| box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); | |
| color: #333; | |
| } | |
| .term-selector { | |
| margin-bottom: 1.5rem; | |
| } | |
| .term-selector select { | |
| font-size: 1rem; | |
| padding: 0.75rem; | |
| } | |
| .definition-box { | |
| background: #f9f9f9; | |
| padding: 1.25rem; | |
| border-radius: 6px; | |
| border-left: 4px solid #646cff; | |
| margin-bottom: 1rem; | |
| min-height: 100px; | |
| } | |
| .info { | |
| color: #666; | |
| font-size: 0.875rem; | |
| } | |
| .info small { | |
| font-size: 0.875rem; | |
| } | |
| .similarity-score { | |
| background: white; | |
| padding: 1.5rem; | |
| border-radius: 8px; | |
| border: 2px solid #646cff; | |
| margin-top: 2rem; | |
| color: #333; | |
| } | |
| .similarity-score h3 { | |
| margin-top: 0; | |
| margin-bottom: 1rem; | |
| color: #646cff; | |
| text-align: center; | |
| } | |
| .similarity-score.no-score { | |
| border-color: #ddd; | |
| background: #f9f9f9; | |
| text-align: center; | |
| } | |
| .similarity-score.no-score p { | |
| margin: 0; | |
| color: #666; | |
| } | |
| .score-display { | |
| text-align: center; | |
| } | |
| .score-display .score-label { | |
| display: block; | |
| font-size: 1.1rem; | |
| color: #555; | |
| font-weight: 500; | |
| margin-bottom: 0.25rem; | |
| text-transform: uppercase; | |
| letter-spacing: 0.05em; | |
| } | |
| .score-display strong { | |
| display: block; | |
| font-size: 3rem; | |
| color: #646cff; | |
| font-weight: 600; | |
| } | |
| /* Info Tooltip Styles */ | |
| .info-tooltip { | |
| position: relative; | |
| display: inline-flex; | |
| align-items: center; | |
| margin-left: 0.35rem; | |
| cursor: help; | |
| } | |
| .info-icon { | |
| color: #888; | |
| vertical-align: middle; | |
| transition: color 0.2s; | |
| } | |
| .info-tooltip:hover .info-icon { | |
| color: #646cff; | |
| } | |
| .tooltip-content { | |
| display: none; | |
| position: absolute; | |
| bottom: calc(100% + 8px); | |
| left: 50%; | |
| transform: translateX(-50%); | |
| background: #333; | |
| color: white; | |
| padding: 0.75rem 1rem; | |
| border-radius: 6px; | |
| font-size: 0.8rem; | |
| white-space: nowrap; | |
| z-index: 100; | |
| box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); | |
| text-transform: none; | |
| letter-spacing: normal; | |
| text-align: left; | |
| } | |
| .tooltip-content::after { | |
| content: ""; | |
| position: absolute; | |
| top: 100%; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| border: 6px solid transparent; | |
| border-top-color: #333; | |
| } | |
| .info-tooltip:hover .tooltip-content { | |
| display: block; | |
| } | |
| .tooltip-content strong { | |
| display: block; | |
| font-size: 0.85rem; | |
| margin-bottom: 0.5rem; | |
| color: #fff; | |
| border-bottom: 1px solid #555; | |
| padding-bottom: 0.4rem; | |
| } | |
| .tooltip-row { | |
| display: flex; | |
| gap: 0.75rem; | |
| padding: 0.2rem 0; | |
| font-weight: normal; | |
| } | |
| .tooltip-range { | |
| font-family: monospace; | |
| color: #a5a8ff; | |
| min-width: 70px; | |
| } | |
| .comparative-note { | |
| margin-top: 1.5rem; | |
| padding-top: 1.5rem; | |
| border-top: 1px solid #e0e0e0; | |
| } | |
| .markdown-content { | |
| line-height: 1.6; | |
| color: #333; | |
| text-align: left; | |
| } | |
| .markdown-content :global(h1), | |
| .markdown-content :global(h2), | |
| .markdown-content :global(h3), | |
| .markdown-content :global(h4), | |
| .markdown-content :global(h5), | |
| .markdown-content :global(h6) { | |
| margin-top: 1em; | |
| margin-bottom: 0.5em; | |
| font-weight: 600; | |
| } | |
| .markdown-content :global(p) { | |
| margin: 0 0 1em 0; | |
| } | |
| .markdown-content :global(ul), | |
| .markdown-content :global(ol) { | |
| margin: 0 0 1em 0; | |
| padding-left: 2em; | |
| } | |
| .markdown-content :global(code) { | |
| background: #f5f5f5; | |
| padding: 0.2em 0.4em; | |
| border-radius: 3px; | |
| font-family: monospace; | |
| font-size: 0.9em; | |
| } | |
| .markdown-content :global(pre) { | |
| background: #f5f5f5; | |
| padding: 1em; | |
| border-radius: 4px; | |
| overflow-x: auto; | |
| } | |
| .markdown-content :global(pre code) { | |
| background: none; | |
| padding: 0; | |
| } | |
| .markdown-content :global(blockquote) { | |
| border-left: 4px solid #646cff; | |
| padding-left: 1em; | |
| margin: 0 0 1em 0; | |
| color: #666; | |
| } | |
| .markdown-content :global(a) { | |
| color: #646cff; | |
| text-decoration: none; | |
| } | |
| .markdown-content :global(a:hover) { | |
| text-decoration: underline; | |
| } | |
| @media (max-width: 768px) { | |
| .comparison-container { | |
| grid-template-columns: 1fr; | |
| gap: 1.5rem; | |
| } | |
| .form-row { | |
| grid-template-columns: 1fr; | |
| } | |
| } | |
| .translation-hint { | |
| display: block; | |
| margin-top: 0.25rem; | |
| color: #666; | |
| font-style: italic; | |
| } | |
| /* Comparisons section styling */ | |
| .comparisons-section { | |
| margin-top: 1.5rem; | |
| padding-top: 1.5rem; | |
| border-top: 1px solid #e0e0e0; | |
| } | |
| .comparisons-header { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr 1fr; | |
| gap: 1rem; | |
| margin-bottom: 0.75rem; | |
| align-items: center; | |
| } | |
| .comparisons-header .header-column { | |
| background: none; | |
| padding: 0.5rem 0.75rem; | |
| color: #333; | |
| font-weight: 700; | |
| } | |
| .comparisons-header .header-column.left { | |
| text-align: left; | |
| padding-left: 0.5rem; | |
| border-right: 1px solid #e0e0e0; | |
| } | |
| .comparisons-header .header-column.center { | |
| text-align: center; | |
| border-right: 1px solid #e0e0e0; | |
| } | |
| .comparisons-header .header-column.right { | |
| text-align: right; | |
| padding-right: 0.5rem; | |
| } | |
| .comparisons-list { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 1rem; | |
| } | |
| .comparison-item { | |
| background: #f9f9f9; | |
| border: 1px solid #e0e0e0; | |
| border-radius: 6px; | |
| padding: 1rem; | |
| transition: box-shadow 0.2s; | |
| } | |
| .comparison-item:hover { | |
| box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); | |
| } | |
| .comparison-header { | |
| display: flex; | |
| align-items: center; | |
| gap: 0.5rem; | |
| margin-bottom: 0.5rem; | |
| flex-wrap: wrap; | |
| } | |
| .comparison-details { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr 1fr; | |
| gap: 1rem; | |
| margin-top: 0.75rem; | |
| align-items: start; | |
| } | |
| .detail-column { | |
| background: #fff; | |
| padding: 0.75rem; | |
| border-radius: 6px; | |
| border: 1px solid #f0f0f0; | |
| min-height: 70px; | |
| } | |
| .detail-column.left { | |
| border-left: 3px solid #646cff; | |
| } | |
| .detail-column.right { | |
| border-right: 3px solid #646cff; | |
| } | |
| .detail-term { | |
| font-weight: 600; | |
| margin-bottom: 0.35rem; | |
| color: #333; | |
| } | |
| .detail-question { | |
| font-size: 0.95rem; | |
| color: #555; | |
| margin-bottom: 0.5rem; | |
| } | |
| .detail-answer { | |
| font-size: 0.9rem; | |
| color: #444; | |
| } | |
| .detail-empty { | |
| color: #888; | |
| font-style: italic; | |
| } | |
| @media (max-width: 900px) { | |
| .comparison-details { | |
| grid-template-columns: 1fr; | |
| } | |
| .comparisons-header { | |
| grid-template-columns: 1fr; | |
| } | |
| .comparisons-header .header-column { | |
| border-right: none; | |
| text-align: left; | |
| padding: 0.25rem 0.5rem; | |
| } | |
| } | |
| .comparison-category { | |
| font-weight: 600; | |
| color: #646cff; | |
| font-size: 0.95rem; | |
| } | |
| .comparison-subcategory { | |
| color: #464444; | |
| font-size: 0.9rem; | |
| } | |
| .comparison-score { | |
| padding: 0.25rem 0.75rem; | |
| border-radius: 12px; | |
| font-weight: 600; | |
| font-size: 0.9rem; | |
| } | |
| .category-meta { | |
| margin-left: auto; | |
| display: flex; | |
| gap: 0.5rem; | |
| align-items: center; | |
| color: #666; | |
| font-size: 0.9rem; | |
| } | |
| .category-weight, | |
| .category-weighted { | |
| background: #f0f0f0; | |
| padding: 0.15rem 0.5rem; | |
| border-radius: 6px; | |
| font-weight: 600; | |
| color: #444; | |
| } | |
| /* Score color variants */ | |
| .comparison-score.score-high { | |
| background: #1a7f37; | |
| color: white; | |
| } | |
| .comparison-score.score-medium { | |
| background: #d98200; | |
| color: white; | |
| } | |
| .comparison-score.score-low { | |
| background: #c33; | |
| color: white; | |
| } | |
| .score-high { | |
| color: #1a7f37; | |
| } | |
| .score-medium { | |
| color: #d98200; | |
| } | |
| .score-low { | |
| color: #c33; | |
| } | |
| /* Fine-grained score variants */ | |
| .comparison-score.score-very-high { | |
| background: #0f6b2d; | |
| color: white; | |
| } | |
| .comparison-score.score-medium-high { | |
| background: #2e9a44; | |
| color: white; | |
| } | |
| .comparison-score.score-medium-low { | |
| background: #f39c12; | |
| color: white; | |
| } | |
| .comparison-score.score-very-low { | |
| background: #9b1f1f; | |
| color: white; | |
| } | |
| .score-very-high { | |
| color: #0f6b2d; | |
| } | |
| .score-medium-high { | |
| color: #2e9a44; | |
| } | |
| .score-medium-low { | |
| color: #f39c12; | |
| } | |
| .score-very-low { | |
| color: #9b1f1f; | |
| } | |
| .comparison-meta { | |
| display: flex; | |
| gap: 1rem; | |
| margin-bottom: 0.5rem; | |
| font-size: 0.85rem; | |
| color: #666; | |
| } | |
| .comparison-weight, | |
| .comparison-weighted-score { | |
| background: #f0f0f0; | |
| padding: 0.25rem 0.5rem; | |
| border-radius: 4px; | |
| } | |
| .comparison-reasoning { | |
| margin-top: 0.5rem; | |
| padding: 0.75rem; | |
| background: white; | |
| border-radius: 4px; | |
| font-size: 0.9rem; | |
| line-height: 1.5; | |
| color: #444; | |
| text-align: left; | |
| } | |
| /* Category filter pills */ | |
| .category-filters { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 0.5rem; | |
| margin-bottom: 1.5rem; | |
| padding-bottom: 1rem; | |
| border-bottom: 1px solid #e0e0e0; | |
| } | |
| .filter-pill { | |
| background: #f0f0f0; | |
| color: #555; | |
| border: 1px solid #ddd; | |
| border-radius: 20px; | |
| padding: 0.5rem 1rem; | |
| font-size: 0.9rem; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| width: auto; | |
| } | |
| .filter-pill:hover { | |
| background: #e0e0e0; | |
| border-color: #999; | |
| } | |
| .filter-pill.active { | |
| background: #646cff; | |
| color: white; | |
| border-color: #646cff; | |
| font-weight: 600; | |
| } | |
| .filter-pill.active:hover { | |
| background: #535bf2; | |
| border-color: #535bf2; | |
| } | |
| .dataset-model-info.subtle { | |
| background: none; | |
| border: none; | |
| padding: 2rem 0; | |
| margin: 0 0 0.5rem 0; | |
| max-width: 100%; | |
| color: #888; | |
| font-size: 0.92rem; | |
| text-align: left; | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 0.5rem; | |
| align-items: center; | |
| } | |
| .dataset-model-info.subtle strong { | |
| color: #888; | |
| font-weight: 500; | |
| margin-right: 0.15em; | |
| } | |
| .dataset-model-info.subtle .sep { | |
| color: #ccc; | |
| font-size: 1.1em; | |
| margin: 0 0.5em; | |
| } | |
| .info-tooltip-inline { | |
| position: relative; | |
| display: inline-flex; | |
| align-items: center; | |
| cursor: help; | |
| } | |
| .info-tooltip-inline strong { | |
| margin-right: 0.15em; | |
| } | |
| .info-tooltip-inline:hover .tooltip-content-inline { | |
| display: block; | |
| } | |
| .tooltip-content-inline { | |
| display: none; | |
| position: absolute; | |
| bottom: calc(100% + 8px); | |
| left: 0; | |
| background: #333; | |
| color: white; | |
| padding: 0.75rem 1rem; | |
| border-radius: 6px; | |
| font-size: 0.8rem; | |
| white-space: normal; | |
| z-index: 100; | |
| box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); | |
| text-align: left; | |
| min-width: 220px; | |
| max-width: 320px; | |
| } | |
| .tooltip-content-inline strong { | |
| display: block; | |
| font-size: 0.85rem; | |
| margin-bottom: 0.5rem; | |
| color: #fff; | |
| border-bottom: 1px solid #555; | |
| padding-bottom: 0.4rem; | |
| } | |
| /* Details loading spinner */ | |
| .details-loading { | |
| display: flex; | |
| align-items: center; | |
| gap: 0.75rem; | |
| padding: 1rem 0; | |
| justify-content: center; | |
| color: #666; | |
| } | |
| .spinner { | |
| width: 28px; | |
| height: 28px; | |
| border: 4px solid #e6e6ff; | |
| border-top-color: #646cff; | |
| border-radius: 50%; | |
| animation: spin 1s linear infinite; | |
| } | |
| .spinner-label { | |
| font-size: 0.95rem; | |
| color: #444; | |
| } | |
| @keyframes spin { | |
| to { | |
| transform: rotate(360deg); | |
| } | |
| } | |
| </style> | |