culture-compass / app.js
guicybercode's picture
Clarify app-level privacy wording
b1d9d4f verified
Raw
History Blame Contribute Delete
28.7 kB
(() => {
"use strict";
const DATASETS = {
japan: {
name: "Japan Math Philosophy Prompts",
url: "https://huggingface.co/datasets/guicybercode/japan-math-philosophy-prompts/resolve/0b472efffdcd5b0b6a166cab21ca318108711629/data/train.jsonl",
source: "https://huggingface.co/datasets/guicybercode/japan-math-philosophy-prompts",
languages: ["pt-BR", "en", "ja"],
},
iceland: {
name: "Iceland Tech & Christian Ethics Prompts",
url: "https://huggingface.co/datasets/guicybercode/iceland-tech-christian-ethics-prompts/resolve/99a46bd5df9d6af3d7226e4836cb28d4d754f3b7/data/train.jsonl",
source: "https://huggingface.co/datasets/guicybercode/iceland-tech-christian-ethics-prompts",
languages: ["pt-BR", "en"],
},
};
const MAX_DATASET_BYTES = 1_000_000;
const MAX_RECORDS = 500;
const REQUEST_TIMEOUT_MS = 15_000;
const LANGUAGE_NAMES = {
"pt-BR": "Português (Brasil)",
en: "English",
ja: "日本語",
};
const COPY = {
"pt-BR": {
pageTitle: "Culture Compass — exploração editorial multilíngue",
metaDescription: "Explore atividades multilíngues experimentais sobre matemática, filosofia, tecnologia e ética.",
skipLink: "Ir para o conteúdo principal",
interfaceLanguage: "Idioma da interface",
eyebrow: "Laboratório editorial multilíngue",
heroTitle: "Ideias para matemática, tecnologia e ética",
heroIntro: "Explore atividades para reflexão com filtros claros de idioma e tema.",
draftTitle: "Aviso de uso",
draftText: "Rascunhos experimentais gerados por IA. Revisão humana é necessária antes de uso educacional, em modelos ou como material curado.",
explorerTitle: "Escolha uma direção",
explorerIntro: "A interface e o conteúdo têm seletores independentes.",
controlsHelp: "Alterar um filtro atualiza a atividade exibida.",
datasetLabel: "Dataset",
contentLanguageLabel: "Idioma do conteúdo",
topicLabel: "Tema ou tópico",
allTopics: "Todos os temas",
randomButton: "Sortear atividade",
activityHeading: "Atividade selecionada",
resultIntro: "O texto abaixo vem diretamente do dataset-fonte.",
loadingTitle: "Carregando atividades",
loadingMessage: "Buscando o arquivo público no Hugging Face.",
emptyTitle: "Nenhuma atividade encontrada",
emptyMessage: "Escolha outro idioma ou tópico para continuar.",
errorTitle: "Não foi possível carregar",
errorMessage: "Confira sua conexão e tente buscar o dataset novamente.",
retryButton: "Tentar novamente",
aiDraftBadge: "Rascunho gerado por IA",
reviewBadge: "Revisão humana necessária",
statusListLabel: "Status editorial",
sourceDisclosure: "Confira contexto, limitações e licença no repositório original.",
sourceLink: "Abrir dataset-fonte no Hugging Face",
footerNote: "Interface estática, sem rastreamento próprio.",
footerNavLabel: "Datasets-fonte",
prompt: "Proposta de atividade",
objectives: "Objetivos de aprendizagem",
answer: "Resposta matemática curta",
reflection: "Critérios para reflexão",
facilitation: "Nota de facilitação",
culturalBoundary: "Limite cultural",
setting: "Cenário ficcional",
question: "Pergunta para discussão",
perspectives: "Perspectivas para debate",
perspective: "Perspectiva {id}",
followups: "Perguntas de aprofundamento",
concepts: "Conceitos éticos sugeridos",
pluralism: "Compromisso com o pluralismo",
fictionality: "Aviso de ficcionalidade",
recordPosition: "{current} de {total}",
loaded: "{count} atividades disponíveis.",
selected: "Atividade pronta para leitura.",
mathFacet: "Matemática",
philosophyFacet: "Filosofia",
technologyFacet: "Tecnologia",
tagFacet: "Tópico",
untitled: "Atividade sem título",
},
en: {
pageTitle: "Culture Compass — multilingual editorial explorer",
metaDescription: "Explore experimental multilingual activities about mathematics, philosophy, technology, and ethics.",
skipLink: "Skip to main content",
interfaceLanguage: "Interface language",
eyebrow: "Multilingual editorial laboratory",
heroTitle: "Ideas for mathematics, technology, and ethics",
heroIntro: "Explore reflective activities with clear language and topic filters.",
draftTitle: "Use notice",
draftText: "Experimental AI-generated drafts. Human review is required before educational use, model use, or treatment as curated material.",
explorerTitle: "Choose a direction",
explorerIntro: "Interface and content languages have independent selectors.",
controlsHelp: "Changing a filter updates the displayed activity.",
datasetLabel: "Dataset",
contentLanguageLabel: "Content language",
topicLabel: "Theme or topic",
allTopics: "All topics",
randomButton: "Choose a random activity",
activityHeading: "Selected activity",
resultIntro: "The text below comes directly from the source dataset.",
loadingTitle: "Loading activities",
loadingMessage: "Fetching the public file from Hugging Face.",
emptyTitle: "No activities found",
emptyMessage: "Choose another language or topic to continue.",
errorTitle: "Unable to load",
errorMessage: "Check your connection and try fetching the dataset again.",
retryButton: "Try again",
aiDraftBadge: "AI-generated draft",
reviewBadge: "Human review required",
statusListLabel: "Editorial status",
sourceDisclosure: "Read the context, limitations, and license in the original repository.",
sourceLink: "Open source dataset on Hugging Face",
footerNote: "Static interface, no app-level tracking.",
footerNavLabel: "Source datasets",
prompt: "Activity prompt",
objectives: "Learning objectives",
answer: "Short mathematical answer",
reflection: "Reflection criteria",
facilitation: "Facilitation note",
culturalBoundary: "Cultural boundary",
setting: "Fictional setting",
question: "Discussion question",
perspectives: "Perspectives for debate",
perspective: "Perspective {id}",
followups: "Follow-up questions",
concepts: "Suggested ethical concepts",
pluralism: "Pluralism commitment",
fictionality: "Fictionality notice",
recordPosition: "{current} of {total}",
loaded: "{count} activities available.",
selected: "Activity ready to read.",
mathFacet: "Mathematics",
philosophyFacet: "Philosophy",
technologyFacet: "Technology",
tagFacet: "Topic",
untitled: "Untitled activity",
},
"zh-CN": {
pageTitle: "Culture Compass — 多语言编辑探索工具",
metaDescription: "探索关于数学、哲学、技术与伦理的多语言实验活动。",
skipLink: "跳到主要内容",
interfaceLanguage: "界面语言",
eyebrow: "多语言编辑实验室",
heroTitle: "数学、技术与伦理的思考素材",
heroIntro: "通过清晰的语言和主题筛选,探索反思活动。",
draftTitle: "使用提示",
draftText: "这些是由人工智能生成的实验性草稿。用于教学、模型或作为精选材料之前,必须经过人工审核。",
explorerTitle: "选择探索方向",
explorerIntro: "界面语言与内容语言可独立选择。",
controlsHelp: "更改筛选条件会更新显示的活动。",
datasetLabel: "数据集",
contentLanguageLabel: "内容语言",
topicLabel: "主题或话题",
allTopics: "所有主题",
randomButton: "随机选择活动",
activityHeading: "已选活动",
resultIntro: "下方文本直接来自源数据集。",
loadingTitle: "正在加载活动",
loadingMessage: "正在从 Hugging Face 获取公开文件。",
emptyTitle: "未找到活动",
emptyMessage: "请选择其他语言或主题后继续。",
errorTitle: "无法加载",
errorMessage: "请检查网络连接,然后重试获取数据集。",
retryButton: "重试",
aiDraftBadge: "AI 生成的草稿",
reviewBadge: "需要人工审核",
statusListLabel: "编辑状态",
sourceDisclosure: "请在原始仓库中查看背景、限制与许可证。",
sourceLink: "在 Hugging Face 打开源数据集",
footerNote: "静态界面,不添加应用级跟踪。",
footerNavLabel: "源数据集",
prompt: "活动任务",
objectives: "学习目标",
answer: "数学简答",
reflection: "反思标准",
facilitation: "引导说明",
culturalBoundary: "文化边界说明",
setting: "虚构场景",
question: "讨论问题",
perspectives: "讨论视角",
perspective: "视角 {id}",
followups: "延伸问题",
concepts: "建议的伦理概念",
pluralism: "多元立场承诺",
fictionality: "虚构性说明",
recordPosition: "第 {current} 项,共 {total} 项",
loaded: "共有 {count} 项活动。",
selected: "活动已可阅读。",
mathFacet: "数学",
philosophyFacet: "哲学",
technologyFacet: "技术",
tagFacet: "话题",
untitled: "无标题活动",
},
};
const state = {
locale: "pt-BR",
dataset: "japan",
records: [],
visible: [],
selectedId: null,
cache: new Map(),
controller: null,
view: "loading",
};
let elements;
function text(key, values = {}) {
const template = COPY[state.locale][key] || COPY.en[key] || key;
return template.replace(/\{(\w+)\}/g, (_, name) => String(values[name] ?? ""));
}
function parseJsonl(source) {
const records = [];
source.split(/\r?\n/).forEach((rawLine, index) => {
const line = rawLine.trim();
if (!line) return;
try {
const record = JSON.parse(line);
if (!record || Array.isArray(record) || typeof record !== "object") {
throw new Error("record must be an object");
}
records.push(record);
} catch (error) {
throw new Error(`Invalid JSONL record at line ${index + 1}`, { cause: error });
}
});
return records;
}
async function readLimitedText(response, maxBytes = MAX_DATASET_BYTES) {
const reader = response.body?.getReader?.();
if (!reader) {
const source = await response.text();
if (new TextEncoder().encode(source).byteLength > maxBytes) {
throw new Error("Dataset response exceeds size limit");
}
return source;
}
const decoder = new TextDecoder();
let receivedBytes = 0;
let source = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
receivedBytes += value.byteLength;
if (receivedBytes > maxBytes) {
await reader.cancel("Dataset response exceeds size limit");
throw new Error("Dataset response exceeds size limit");
}
source += decoder.decode(value, { stream: true });
}
return source + decoder.decode();
} catch (error) {
try {
await reader.cancel();
} catch {
// The stream may already be closed, cancelled, or aborted.
}
throw error;
} finally {
reader.releaseLock?.();
}
}
function groupId(record) {
return record.localization_group_id || record.pair_id || null;
}
function validateRecords(records) {
const ids = new Set();
records.forEach((record) => {
if (typeof record.id !== "string" || typeof record.language !== "string" || typeof groupId(record) !== "string") {
throw new Error("Dataset record is missing a stable identity or language");
}
if (ids.has(record.id)) throw new Error("Dataset contains a duplicate record id");
ids.add(record.id);
});
return records;
}
function arrayOfText(value) {
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
}
function slugLabel(value) {
return String(value).replace(/[-_]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
function topicFacets(records, datasetKey = state.dataset) {
const facets = new Map();
const add = (kind, value) => {
if (typeof value !== "string" || !value.trim()) return;
facets.set(`${kind}:${value}`, { kind, value });
};
records.forEach((record) => {
if (datasetKey === "japan") {
add("math", record.math_topic);
add("philosophy", record.philosophical_focus);
} else {
add("technology", record.technology_domain);
arrayOfText(record.topic_tags).forEach((tag) => add("tag", tag));
}
});
return [...facets.values()].sort((a, b) => a.value.localeCompare(b.value));
}
function normalizeJapanRecord(record) {
return {
id: record.id,
groupId: groupId(record),
language: record.language,
kind: "japan",
title: record.title,
facets: topicFacets([record], "japan"),
raw: record,
};
}
function normalizeIcelandRecord(record) {
return {
id: record.id,
groupId: groupId(record),
language: record.language,
kind: "iceland",
title: record.scenario_title,
facets: topicFacets([record], "iceland"),
raw: record,
};
}
function buildActivities(records, datasetKey) {
const normalize = datasetKey === "iceland" ? normalizeIcelandRecord : normalizeJapanRecord;
return records.map(normalize);
}
function filterActivities(records, language, topic = "all") {
return records.filter((record) => record.language === language && matchesTopic(record, topic));
}
function selectRandom(records, currentId = null, random = Math.random) {
if (!records.length) return null;
const pool = records.length > 1 ? records.filter((record) => record.id !== currentId) : records;
return pool[Math.floor(random() * pool.length)] || pool[0] || null;
}
function getAvailableLanguages(records, datasetKey) {
const present = new Set(records.map((record) => record.language));
const config = DATASETS[datasetKey];
return config ? config.languages.filter((language) => present.has(language)) : [...present];
}
function getAvailableTopics(records, datasetKey, language) {
const localized = language ? records.filter((record) => record.language === language) : records;
return topicFacets(localized, datasetKey);
}
function facetLabel(facet) {
const prefixes = {
math: text("mathFacet"),
philosophy: text("philosophyFacet"),
technology: text("technologyFacet"),
tag: text("tagFacet"),
};
return `${prefixes[facet.kind]}${slugLabel(facet.value)}`;
}
function matchesTopic(record, selection) {
if (selection === "all") return true;
const separator = selection.indexOf(":");
const kind = selection.slice(0, separator);
const value = selection.slice(separator + 1);
if (kind === "math") return record.math_topic === value;
if (kind === "philosophy") return record.philosophical_focus === value;
if (kind === "technology") return record.technology_domain === value;
if (kind === "tag") return arrayOfText(record.topic_tags).includes(value);
return false;
}
function make(tag, className, content) {
const node = document.createElement(tag);
if (className) node.className = className;
if (content !== undefined && content !== null) node.textContent = String(content);
return node;
}
function remoteText(tag, className, content, language) {
const node = make(tag, className, content);
if (language) node.lang = language;
return node;
}
function appendList(parent, items, className = "", language = null) {
const list = make("ul", className);
items.forEach((item) => list.append(remoteText("li", "", item, language)));
parent.append(list);
}
function section(parent, heading, className = "") {
const wrapper = make("section", `activity-section ${className}`.trim());
wrapper.append(make("h4", "", heading));
parent.append(wrapper);
return wrapper;
}
function renderJapan(record, body) {
const prompt = section(body, text("prompt"));
prompt.append(remoteText("p", "prompt-block", record.prompt, record.language));
const objectives = arrayOfText(record.learning_objectives);
if (objectives.length) appendList(section(body, text("objectives")), objectives, "", record.language);
if (record.expected_response && typeof record.expected_response.short_answer === "string") {
section(body, text("answer")).append(
remoteText("p", "", record.expected_response.short_answer, record.language),
);
}
const criteria = record.expected_response ? arrayOfText(record.expected_response.reflection_criteria) : [];
if (criteria.length) appendList(section(body, text("reflection")), criteria, "", record.language);
if (typeof record.facilitation_note === "string") {
section(body, text("facilitation")).append(
remoteText("p", "", record.facilitation_note, record.language),
);
}
const boundary = record.japan_reference && record.japan_reference.boundary_note;
if (typeof boundary === "string") {
section(body, text("culturalBoundary"), "callout").append(
remoteText("p", "", boundary, record.language),
);
}
}
function renderIceland(record, body) {
if (typeof record.setting === "string") {
section(body, text("setting")).append(
remoteText("p", "prompt-block", record.setting, record.language),
);
}
if (typeof record.discussion_question === "string") {
section(body, text("question")).append(
remoteText("p", "question-block", record.discussion_question, record.language),
);
}
if (Array.isArray(record.perspectives) && record.perspectives.length) {
const wrapper = section(body, text("perspectives"));
const grid = make("div", "perspectives-grid");
record.perspectives.forEach((perspective, index) => {
const card = make("section", "perspective-card");
const id = typeof perspective.id === "string" ? perspective.id.toUpperCase() : String(index + 1);
card.append(make("p", "perspective-label", text("perspective", { id })));
card.append(remoteText("h5", "", perspective.title, record.language));
card.append(remoteText("p", "", perspective.argument, record.language));
const questions = arrayOfText(perspective.questions);
if (questions.length) {
card.append(make("h6", "", text("followups")));
appendList(card, questions, "", record.language);
}
grid.append(card);
});
wrapper.append(grid);
}
const concepts = arrayOfText(record.christian_ethics_concepts);
if (concepts.length) {
appendList(section(body, text("concepts")), concepts.map(slugLabel), "tag-list", "en");
}
const safeguard = record.pluralism && record.pluralism.safeguard;
if (typeof safeguard === "string") {
section(body, text("pluralism"), "callout").append(
remoteText("p", "", safeguard, record.language),
);
}
const notice = record.fictionality && record.fictionality.notice;
if (typeof notice === "string") {
section(body, text("fictionality"), "callout").append(
remoteText("p", "", notice, record.language),
);
}
}
function setPanel(view) {
state.view = view;
elements.card.hidden = true;
elements.panel.hidden = false;
elements.panel.dataset.state = view;
const titles = { loading: "loadingTitle", empty: "emptyTitle", error: "errorTitle" };
const messages = { loading: "loadingMessage", empty: "emptyMessage", error: "errorMessage" };
elements.stateTitle.textContent = text(titles[view]);
elements.stateMessage.textContent = text(messages[view]);
elements.retry.hidden = view !== "error";
elements.region.setAttribute("aria-busy", String(view === "loading"));
elements.live.textContent = `${text(titles[view])}. ${text(messages[view])}`;
}
function renderRecord(record) {
state.view = "record";
elements.panel.hidden = true;
elements.card.hidden = false;
elements.region.setAttribute("aria-busy", "false");
elements.card.lang = state.locale;
elements.title.textContent = record.title || record.scenario_title || text("untitled");
elements.title.lang = record.language;
elements.context.textContent = DATASETS[state.dataset].name;
elements.context.lang = "en";
const current = state.visible.findIndex((item) => item.id === record.id) + 1;
elements.position.textContent = text("recordPosition", { current, total: state.visible.length });
elements.body.replaceChildren();
if (state.dataset === "japan") renderJapan(record, elements.body);
else renderIceland(record, elements.body);
elements.source.href = DATASETS[state.dataset].source;
state.selectedId = record.id;
elements.live.textContent = text("selected");
}
function refreshResult(random = false) {
const language = elements.contentLanguage.value;
const topic = elements.topic.value;
state.visible = filterActivities(state.records, language, topic);
if (!state.visible.length) {
state.selectedId = null;
setPanel("empty");
return;
}
if (random) {
renderRecord(selectRandom(state.visible, state.selectedId));
return;
}
renderRecord(state.visible[0]);
}
function populateTopics() {
const previous = elements.topic.value;
const languageRecords = state.records.filter((record) => record.language === elements.contentLanguage.value);
const facets = getAvailableTopics(languageRecords, state.dataset);
elements.topic.replaceChildren();
const all = make("option", "", text("allTopics"));
all.value = "all";
all.lang = state.locale;
elements.topic.append(all);
facets.forEach((facet) => {
const option = make("option", "", facetLabel(facet));
option.value = `${facet.kind}:${facet.value}`;
option.lang = elements.contentLanguage.value;
elements.topic.append(option);
});
const valid = [...elements.topic.options].some((option) => option.value === previous);
elements.topic.value = valid ? previous : "all";
}
function populateLanguages(available) {
const preferred = elements.contentLanguage.value;
elements.contentLanguage.replaceChildren();
available.forEach((language) => {
const option = make("option", "", LANGUAGE_NAMES[language] || language);
option.value = language;
option.lang = language;
elements.contentLanguage.append(option);
});
elements.contentLanguage.value = available.includes(preferred) ? preferred : (available[0] || "");
}
function setControlsLoading(isLoading) {
elements.contentLanguage.disabled = isLoading;
elements.topic.disabled = isLoading;
elements.random.disabled = isLoading;
}
async function loadDataset(force = false, manageFocus = false) {
const datasetKey = state.dataset;
const config = DATASETS[datasetKey];
if (state.controller) state.controller.abort();
state.controller = new AbortController();
const controller = state.controller;
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
setPanel("loading");
if (manageFocus) elements.stateTitle.focus?.();
setControlsLoading(true);
populateLanguages(config.languages);
try {
let records = !force ? state.cache.get(datasetKey) : null;
if (!records) {
const response = await fetch(config.url, {
credentials: "omit",
headers: { Accept: "application/json, text/plain;q=0.9" },
referrerPolicy: "no-referrer",
signal: controller.signal,
});
if (!response.ok) throw new Error(`Dataset request failed with status ${response.status}`);
const declaredSize = Number(response.headers?.get?.("Content-Length"));
if (Number.isFinite(declaredSize) && declaredSize > MAX_DATASET_BYTES) {
throw new Error("Dataset response exceeds size limit");
}
const source = await readLimitedText(response);
records = validateRecords(parseJsonl(source));
if (records.length > MAX_RECORDS) {
throw new Error("Dataset record count exceeds limit");
}
if (controller.signal.aborted || datasetKey !== state.dataset) return;
state.cache.set(datasetKey, records);
}
if (controller.signal.aborted || datasetKey !== state.dataset) return;
state.records = records;
populateLanguages(getAvailableLanguages(records, datasetKey));
populateTopics();
setControlsLoading(false);
if (!records.length || !elements.contentLanguage.value) setPanel("empty");
else {
elements.live.textContent = text("loaded", { count: records.length });
refreshResult();
if (manageFocus) elements.title.focus?.();
}
} catch (error) {
if (error && error.name === "AbortError" && controller !== state.controller) return;
setControlsLoading(false);
setPanel("error");
} finally {
clearTimeout(timeoutId);
if (state.controller === controller) state.controller = null;
}
}
function applyLocale() {
document.documentElement.lang = state.locale;
document.title = text("pageTitle");
document.querySelectorAll("[data-i18n]").forEach((node) => {
node.textContent = text(node.dataset.i18n);
});
document.querySelectorAll("[data-i18n-aria]").forEach((node) => {
node.setAttribute("aria-label", text(node.dataset.i18nAria));
});
document.querySelectorAll("[data-i18n-content]").forEach((node) => {
node.setAttribute("content", text(node.dataset.i18nContent));
});
if (state.records.length) {
populateTopics();
const selected = state.records.find((record) => record.id === state.selectedId);
if (selected) renderRecord(selected);
else if (state.view === "record") refreshResult();
else setPanel(state.view);
} else {
setPanel(state.view);
}
}
function init() {
elements = {
uiLanguage: document.getElementById("ui-language"),
dataset: document.getElementById("dataset-select"),
contentLanguage: document.getElementById("content-language"),
topic: document.getElementById("topic-select"),
random: document.getElementById("random-button"),
retry: document.getElementById("retry-button"),
panel: document.getElementById("state-panel"),
stateTitle: document.getElementById("state-title"),
stateMessage: document.getElementById("state-message"),
region: document.getElementById("result-region"),
live: document.getElementById("live-status"),
card: document.getElementById("activity-card"),
title: document.getElementById("activity-title"),
context: document.getElementById("activity-context"),
position: document.getElementById("record-position"),
body: document.getElementById("activity-body"),
source: document.getElementById("source-link"),
};
elements.uiLanguage.addEventListener("change", () => {
state.locale = elements.uiLanguage.value;
applyLocale();
});
elements.dataset.addEventListener("change", () => {
state.dataset = elements.dataset.value;
state.records = [];
state.selectedId = null;
loadDataset();
});
elements.contentLanguage.addEventListener("change", () => {
populateTopics();
refreshResult();
});
elements.topic.addEventListener("change", () => refreshResult());
elements.random.addEventListener("click", () => refreshResult(true));
elements.retry.addEventListener("click", () => loadDataset(true, true));
applyLocale();
loadDataset();
}
if (typeof document !== "undefined") {
document.addEventListener("DOMContentLoaded", init, { once: true });
}
if (typeof module !== "undefined" && module.exports) {
module.exports = {
parseJsonl,
readLimitedText,
groupId,
validateRecords,
buildActivities,
filterActivities,
selectRandom,
getAvailableLanguages,
getAvailableTopics,
normalizeJapanRecord,
normalizeIcelandRecord,
TRANSLATIONS: COPY,
DATASETS,
};
}
})();