diff --git a/.gitignore b/.gitignore index 1a0b09fe..df712fb0 100644 --- a/.gitignore +++ b/.gitignore @@ -276,6 +276,7 @@ pkg-py/docs/_screenshots/ # Git worktrees .worktrees/ +js/node_modules/ # Superpowers docs (local only) docs/superpowers/ diff --git a/Makefile b/Makefile index 6e86f085..0743ff1a 100644 --- a/Makefile +++ b/Makefile @@ -52,6 +52,21 @@ docs: r-docs py-docs-render ## [docs] Build the documentation # @echo "🧳 Building JS code in watch mode" # cd $(PATH_PKG_JS) && npm run watch +.PHONY: web-setup +web-setup: ## [js] Install shared web asset dependencies + @echo "🆙 Setup shared web asset dependencies" + cd $(PATH_PKG_JS) && npm ci + +.PHONY: web-build +web-build: ## [js] Build shared web assets + @echo "🧳 Building shared web assets" + cd $(PATH_PKG_JS) && npm run build + +.PHONY: web-check +web-check: ## [js] Check shared web assets + @echo "📐 Checking shared web assets" + cd $(PATH_PKG_JS) && npm run check + .PHONY: r-setup r-setup: ## [r] Install R dependencies @echo "🆙 Updating R dependencies" diff --git a/js/build.mjs b/js/build.mjs new file mode 100644 index 00000000..5aa0bff9 --- /dev/null +++ b/js/build.mjs @@ -0,0 +1,149 @@ +import { build } from "esbuild"; +import { + access, + copyFile, + mkdir, + mkdtemp, + readFile, + rm, + writeFile, +} from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const rootDir = path.dirname(fileURLToPath(import.meta.url)); +export const repoDir = path.resolve(rootDir, ".."); + +const jsTargets = [ + { + source: "src/viz-r.ts", + output: "../pkg-r/inst/htmldep/viz.js", + }, + { + source: "src/viz-py.ts", + output: "../pkg-py/src/querychat/static/js/viz.js", + }, +]; + +const cssTargets = [ + { + source: "src/viz.css", + output: "../pkg-r/inst/htmldep/viz.css", + }, + { + source: "src/viz.css", + output: "../pkg-py/src/querychat/static/css/viz.css", + }, +]; + +const ensureParentDir = async (relativePath) => { + const absolutePath = path.resolve(rootDir, relativePath); + await mkdir(path.dirname(absolutePath), { recursive: true }); + return absolutePath; +}; + +export const assetTargets = [...cssTargets, ...jsTargets]; + +export const resolveOutputPath = (baseDir, relativePath) => + path.resolve(baseDir, path.relative(repoDir, path.resolve(rootDir, relativePath))); + +const banner = (source) => + `/* Generated file. Source: js/${source}. Do not edit directly. */\n`; + +const uniqueSources = (targets) => [...new Set(targets.map((target) => target.source))]; + +const findMissingSources = async (targets) => { + const missingSources = []; + + for (const source of uniqueSources(targets)) { + try { + await access(path.resolve(rootDir, source)); + } catch { + missingSources.push(`js/${source}`); + } + } + + return missingSources; +}; + +const reportMissingSources = async () => { + const missingCssSources = await findMissingSources(cssTargets); + const missingJsSources = await findMissingSources(jsTargets); + + if (missingCssSources.length === 0 && missingJsSources.length === 0) { + return; + } + + const messages = []; + + if (missingCssSources.length > 0) { + messages.push(`Missing CSS source files:\n- ${missingCssSources.join("\n- ")}`); + } + + if (missingJsSources.length > 0) { + messages.push(`Missing JS source files:\n- ${missingJsSources.join("\n- ")}`); + } + + throw new Error(messages.join("\n\n")); +}; + +export const stageBuildOutputs = async (stageDir) => { + const cssSourcePath = path.resolve(rootDir, "src/viz.css"); + const cssSource = await readFile(cssSourcePath, "utf8"); + + for (const target of cssTargets) { + const outputPath = resolveOutputPath(stageDir, target.output); + await mkdir(path.dirname(outputPath), { recursive: true }); + await writeFile(outputPath, `${banner(target.source)}${cssSource}`, "utf8"); + } + + for (const target of jsTargets) { + const outputPath = resolveOutputPath(stageDir, target.output); + await mkdir(path.dirname(outputPath), { recursive: true }); + await build({ + bundle: true, + entryPoints: [path.resolve(rootDir, target.source)], + format: "iife", + logLevel: "info", + outfile: outputPath, + platform: "browser", + target: "es2020", + banner: { + js: banner(target.source), + }, + }); + } +}; + +export const commitBuildOutputs = async (stageDir) => { + for (const target of assetTargets) { + const stagedOutputPath = resolveOutputPath(stageDir, target.output); + await ensureParentDir(target.output); + await copyFile(stagedOutputPath, path.resolve(rootDir, target.output)); + } +}; + +export async function withStagedBuild(callback) { + await reportMissingSources(); + + const stageDir = await mkdtemp(path.join(os.tmpdir(), "querychat-build-")); + + try { + await stageBuildOutputs(stageDir); + return await callback(stageDir); + } finally { + await rm(stageDir, { force: true, recursive: true }); + } +} + +export async function buildOutputs() { + await withStagedBuild(commitBuildOutputs); +} + +const isEntrypoint = + process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (isEntrypoint) { + await buildOutputs(); +} diff --git a/js/check.mjs b/js/check.mjs new file mode 100644 index 00000000..2600c255 --- /dev/null +++ b/js/check.mjs @@ -0,0 +1,83 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import ts from "typescript"; +import { assetTargets, repoDir, resolveOutputPath, withStagedBuild } from "./build.mjs"; + +const configPath = "tsconfig.json"; +const formatHost = { + getCanonicalFileName: (fileName) => fileName, + getCurrentDirectory: ts.sys.getCurrentDirectory, + getNewLine: () => ts.sys.newLine, +}; + +const readResult = ts.readConfigFile(configPath, ts.sys.readFile); +if (readResult.error) { + console.error(ts.formatDiagnosticsWithColorAndContext([readResult.error], formatHost)); + process.exit(1); +} + +const parsedConfig = ts.parseJsonConfigFileContent( + readResult.config, + ts.sys, + process.cwd(), + undefined, + configPath, +); + +const configErrors = parsedConfig.errors.filter((error) => error.code !== 18003); +if (configErrors.length > 0) { + console.error(ts.formatDiagnosticsWithColorAndContext(configErrors, formatHost)); + process.exit(1); +} + +if (parsedConfig.fileNames.length > 0) { + const program = ts.createProgram({ + options: parsedConfig.options, + rootNames: parsedConfig.fileNames, + }); + + const diagnostics = ts.getPreEmitDiagnostics(program); + if (diagnostics.length > 0) { + console.error(ts.formatDiagnosticsWithColorAndContext(diagnostics, formatHost)); + process.exit(1); + } +} + +const staleOutputs = []; + +await withStagedBuild(async (stageDir) => { + for (const target of assetTargets) { + const stagedOutputPath = resolveOutputPath(stageDir, target.output); + const committedOutputPath = resolveOutputPath(repoDir, target.output); + const relativeOutputPath = path.relative(repoDir, committedOutputPath); + + let stagedOutput; + let committedOutput; + + try { + [stagedOutput, committedOutput] = await Promise.all([ + readFile(stagedOutputPath), + readFile(committedOutputPath), + ]); + } catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { + staleOutputs.push(relativeOutputPath); + continue; + } + + throw error; + } + + if (!stagedOutput.equals(committedOutput)) { + staleOutputs.push(relativeOutputPath); + } + } +}); + +if (staleOutputs.length > 0) { + console.error("Generated web assets are out of sync. Run `make web-build`."); + for (const outputPath of staleOutputs) { + console.error(`- ${outputPath}`); + } + process.exit(1); +} diff --git a/js/package-lock.json b/js/package-lock.json new file mode 100644 index 00000000..7953eda3 --- /dev/null +++ b/js/package-lock.json @@ -0,0 +1,458 @@ +{ + "name": "@querychat/shared-viz-assets", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@querychat/shared-viz-assets", + "devDependencies": { + "esbuild": "^0.21.5", + "typescript": "^5.5.4" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/js/package.json b/js/package.json new file mode 100644 index 00000000..3fd3ce29 --- /dev/null +++ b/js/package.json @@ -0,0 +1,13 @@ +{ + "name": "@querychat/shared-viz-assets", + "private": true, + "type": "module", + "scripts": { + "build": "node build.mjs", + "check": "node check.mjs" + }, + "devDependencies": { + "esbuild": "^0.21.5", + "typescript": "^5.5.4" + } +} diff --git a/js/src/viz-core.ts b/js/src/viz-core.ts new file mode 100644 index 00000000..74beb222 --- /dev/null +++ b/js/src/viz-core.ts @@ -0,0 +1,219 @@ +type ExportFormat = "png" | "svg"; +type QuerychatAction = + | "show-query" + | "save-toggle" + | "save-png" + | "save-svg" + | "copy"; + +export interface VizRuntimeAdapter { + exportPlot(widgetId: string, format: ExportFormat, filename: string): void; +} + +function findWidgetContainer(widgetId: string): HTMLElement | null { + return document.getElementById(widgetId); +} + +function findVegaAction( + container: HTMLElement, + format: ExportFormat, +): HTMLAnchorElement | null { + return container.querySelector( + `.vega-actions a[download$=".${format}"]`, + ); +} + +function triggerVegaAction(link: HTMLAnchorElement, filename: string): void { + link.download = filename; + + if (link.href && link.href !== "#" && !link.href.endsWith("#")) { + link.click(); + return; + } + + const observer = new MutationObserver(() => { + if (link.href && link.href !== "#" && !link.href.endsWith("#")) { + observer.disconnect(); + clearTimeout(timeoutId); + link.click(); + } + }); + + observer.observe(link, { + attributes: true, + attributeFilter: ["href"], + }); + + const timeoutId = window.setTimeout(() => { + observer.disconnect(); + console.error("Timed out waiting for vega-embed to generate image"); + }, 5000); + + link.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); +} + +let openSaveMenu: HTMLElement | null = null; + +function closeSaveMenu(menu: HTMLElement): void { + menu.classList.remove("querychat-save-menu--visible"); + + if (openSaveMenu === menu) { + openSaveMenu = null; + } +} + +function closeOpenSaveMenu(): void { + if (openSaveMenu) { + closeSaveMenu(openSaveMenu); + } +} + +function handleShowQuery(event: MouseEvent, button: HTMLElement): void { + event.stopPropagation(); + + const targetId = button.dataset.target; + if (!targetId) { + return; + } + + const section = document.getElementById(targetId); + if (!section) { + return; + } + + const isVisible = section.classList.toggle("querychat-query-section--visible"); + const label = button.querySelector(".querychat-query-label"); + const chevron = button.querySelector(".querychat-query-chevron"); + + if (label) { + label.textContent = isVisible ? "Hide Query" : "Show Query"; + } + + if (chevron) { + chevron.classList.toggle("querychat-query-chevron--expanded", isVisible); + } +} + +function handleSaveToggle(event: MouseEvent, button: HTMLElement): void { + event.stopPropagation(); + + const menu = button.parentElement?.querySelector( + ".querychat-save-menu", + ); + + if (!menu) { + return; + } + + if (openSaveMenu && openSaveMenu !== menu) { + closeSaveMenu(openSaveMenu); + } + + if (menu.classList.contains("querychat-save-menu--visible")) { + closeSaveMenu(menu); + } else { + menu.classList.add("querychat-save-menu--visible"); + openSaveMenu = menu; + } +} + +function handleSaveExport( + event: MouseEvent, + button: HTMLElement, + format: ExportFormat, + adapter: VizRuntimeAdapter, +): void { + event.stopPropagation(); + + const widgetId = button.dataset.widgetId; + if (!widgetId) { + return; + } + + const filename = button.dataset.title || "chart"; + const menu = button.closest(".querychat-save-menu"); + if (menu) { + closeSaveMenu(menu); + } + + adapter.exportPlot(widgetId, format, filename); +} + +function handleCopy(event: MouseEvent, button: HTMLElement): void { + event.stopPropagation(); + + const query = button.dataset.query; + if (!query) { + return; + } + + navigator.clipboard + .writeText(query) + .then(() => { + const original = button.textContent; + button.textContent = "Copied!"; + setTimeout(() => { + button.textContent = original; + }, 2000); + }) + .catch((error: unknown) => { + console.error("Failed to copy:", error); + }); +} + +export function installVizFooter(adapter: VizRuntimeAdapter): void { + window.addEventListener("click", (event) => { + const target = event.target; + + if (!(target instanceof Element)) { + closeOpenSaveMenu(); + return; + } + + const actionElement = target.closest("[data-querychat-action]"); + const action = actionElement?.dataset.querychatAction as + | QuerychatAction + | undefined; + + if (!action || !actionElement) { + closeOpenSaveMenu(); + return; + } + + switch (action) { + case "show-query": + handleShowQuery(event, actionElement); + return; + case "save-toggle": + handleSaveToggle(event, actionElement); + return; + case "save-png": + handleSaveExport(event, actionElement, "png", adapter); + return; + case "save-svg": + handleSaveExport(event, actionElement, "svg", adapter); + return; + case "copy": + handleCopy(event, actionElement); + return; + } + }); +} + +export function createVegaActionAdapter(): VizRuntimeAdapter { + return { + exportPlot(widgetId, format, filename) { + const container = findWidgetContainer(widgetId); + if (!container) { + return; + } + + const link = findVegaAction(container, format); + if (!link) { + return; + } + + triggerVegaAction(link, `${filename}.${format}`); + }, + }; +} diff --git a/js/src/viz-py.ts b/js/src/viz-py.ts new file mode 100644 index 00000000..4be946cd --- /dev/null +++ b/js/src/viz-py.ts @@ -0,0 +1,3 @@ +import { createVegaActionAdapter, installVizFooter } from "./viz-core"; + +installVizFooter(createVegaActionAdapter()); diff --git a/js/src/viz-r.ts b/js/src/viz-r.ts new file mode 100644 index 00000000..4be946cd --- /dev/null +++ b/js/src/viz-r.ts @@ -0,0 +1,3 @@ +import { createVegaActionAdapter, installVizFooter } from "./viz-core"; + +installVizFooter(createVegaActionAdapter()); diff --git a/js/src/viz.css b/js/src/viz.css new file mode 100644 index 00000000..cabbc42a --- /dev/null +++ b/js/src/viz.css @@ -0,0 +1,141 @@ +/* Hide Vega's built-in action dropdown (we have our own save button) */ +.querychat-viz-container details:has(> .vega-actions) { + display: none !important; +} + +/* ---- Visualization container ---- */ + +.querychat-viz-container { + aspect-ratio: 4 / 2; + width: 100%; +} + +/* In full-screen mode, let the chart fill the available space */ +.bslib-full-screen-container .querychat-viz-container { + aspect-ratio: unset; +} + +/* ---- Visualization footer ---- */ + +.querychat-footer-buttons { + display: flex; + justify-content: space-between; + align-items: center; +} + +.querychat-footer-left, +.querychat-footer-right { + display: flex; + align-items: center; + gap: 4px; +} + +.querychat-show-query-btn, +.querychat-save-btn { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + height: 28px; + border: none; + border-radius: var(--bs-border-radius, 4px); + background: transparent; + color: var(--bs-secondary-color, #6c757d); + font-size: 0.75rem; + cursor: pointer; + white-space: nowrap; +} + +.querychat-show-query-btn:hover, +.querychat-save-btn:hover { + color: var(--bs-body-color, #212529); + background-color: rgba(var(--bs-emphasis-color-rgb, 0, 0, 0), 0.05); +} + +.querychat-query-chevron { + font-size: 0.625rem; + transition: transform 150ms; + display: inline-block; +} + +.querychat-query-chevron--expanded { + transform: rotate(90deg); +} + +.querychat-icon { + width: 14px; + height: 14px; +} + +.querychat-dropdown-chevron { + width: 12px; + height: 12px; + margin-left: 2px; +} + +.querychat-save-dropdown { + position: relative; +} + +.querychat-save-menu { + display: none; + position: absolute; + right: 0; + bottom: 100%; + margin-bottom: 4px; + z-index: 20; + background: var(--bs-body-bg, #fff); + border: 1px solid var(--bs-border-color, #dee2e6); + border-radius: var(--bs-border-radius, 4px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + padding: 4px 0; + min-width: 120px; +} + +.querychat-save-menu--visible { + display: block; +} + +.querychat-save-menu button { + display: block; + width: 100%; + padding: 6px 12px; + border: none; + background: transparent; + color: var(--bs-body-color, #212529); + font-size: 0.75rem; + text-align: left; + cursor: pointer; +} + +.querychat-save-menu button:hover { + background-color: rgba(var(--bs-emphasis-color-rgb, 0, 0, 0), 0.05); +} + +.querychat-query-section { + display: none; + position: relative; + border-top: 1px solid var(--bs-border-color, #dee2e6); + margin: 8px -16px -8px; +} + +.querychat-query-section--visible { + display: block; +} + + +/* shinychat sets max-height:500px on all cards, which is too small for viz+editor */ +.shiny-tool-card:has(.querychat-viz-container) { + max-height: 700px; + overflow: hidden; +} + +.querychat-query-section bslib-code-editor .code-editor { + margin: 1em; +} + +.querychat-query-section bslib-code-editor .prism-code-editor { + background-color: var(--bs-light, #f8f8f8); + max-height: 200px; + overflow-y: auto; +} diff --git a/js/tsconfig.json b/js/tsconfig.json new file mode 100644 index 00000000..82c7ad7c --- /dev/null +++ b/js/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["DOM", "ES2020"], + "strict": true, + "isolatedModules": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/pkg-py/src/querychat/_viz_tools.py b/pkg-py/src/querychat/_viz_tools.py index 118ff2e4..51e19cbf 100644 --- a/pkg-py/src/querychat/_viz_tools.py +++ b/pkg-py/src/querychat/_viz_tools.py @@ -13,6 +13,7 @@ from shinychat.types import ToolResultDisplay from shiny import ui +from shiny.module import resolve_id from .__version import __version__ from ._icons import bs_icon @@ -114,7 +115,12 @@ def __init__( else: value = text - footer = build_viz_footer(ggsql_str, title, widget_id) + footer = build_viz_footer( + ggsql_str, + title, + widget_id, + dom_widget_id=resolve_viz_dom_id(widget_id), + ) widget_html = output_widget(widget_id, fill=True, fillable=True) widget_html.add_class("querychat-viz-container") @@ -237,6 +243,7 @@ def build_viz_footer( ggsql_str: str, title: str, widget_id: str, + dom_widget_id: str = "", ) -> TagList: """Build footer HTML for visualization tool results.""" footer_id = f"querychat_footer_{uuid4().hex[:8]}" @@ -269,6 +276,7 @@ def build_viz_footer( tags.button( { "class": "querychat-show-query-btn", + "data-querychat-action": "show-query", "data-target": query_section_id, }, tags.span({"class": "querychat-query-chevron"}, "\u25b6"), @@ -281,10 +289,11 @@ def build_viz_footer( tags.div( {"class": "querychat-save-dropdown"}, tags.button( - { - "class": "querychat-save-btn", - "data-widget-id": widget_id, - }, + { + "class": "querychat-save-btn", + "data-querychat-action": "save-toggle", + "data-widget-id": dom_widget_id or widget_id, + }, bs_icon("download", cls="querychat-icon"), "Save", bs_icon("chevron-down", cls="querychat-dropdown-chevron"), @@ -294,7 +303,8 @@ def build_viz_footer( tags.button( { "class": "querychat-save-png-btn", - "data-widget-id": widget_id, + "data-querychat-action": "save-png", + "data-widget-id": dom_widget_id or widget_id, "data-title": title, }, "Save as PNG", @@ -302,7 +312,8 @@ def build_viz_footer( tags.button( { "class": "querychat-save-svg-btn", - "data-widget-id": widget_id, + "data-querychat-action": "save-svg", + "data-widget-id": dom_widget_id or widget_id, "data-title": title, }, "Save as SVG", @@ -313,3 +324,8 @@ def build_viz_footer( ) return TagList(buttons_row, query_section) + + +def resolve_viz_dom_id(widget_id: str) -> str: + """Resolve a widget id to the final DOM id rendered by Shiny.""" + return str(resolve_id(widget_id)) diff --git a/pkg-py/src/querychat/static/css/viz.css b/pkg-py/src/querychat/static/css/viz.css index 1b5812bc..5e95c6d0 100644 --- a/pkg-py/src/querychat/static/css/viz.css +++ b/pkg-py/src/querychat/static/css/viz.css @@ -1,3 +1,4 @@ +/* Generated file. Source: js/src/viz.css. Do not edit directly. */ /* Hide Vega's built-in action dropdown (we have our own save button) */ .querychat-viz-container details:has(> .vega-actions) { display: none !important; @@ -138,4 +139,4 @@ background-color: var(--bs-light, #f8f8f8); max-height: 200px; overflow-y: auto; -} \ No newline at end of file +} diff --git a/pkg-py/src/querychat/static/js/viz.js b/pkg-py/src/querychat/static/js/viz.js index a0447517..e611b80a 100644 --- a/pkg-py/src/querychat/static/js/viz.js +++ b/pkg-py/src/querychat/static/js/viz.js @@ -1,129 +1,166 @@ -// Helper: find a native vega-embed action link inside a widget container. -// vega-embed renders a hidden
with tags for "Save as SVG", -// "Save as PNG", etc. We find them by matching the download attribute suffix. -// -// Why not use the Vega View API (view.toSVG(), view.toImageURL()) directly? -// Altair renders charts via its anywidget ESM, which calls vegaEmbed() and -// stores the resulting View in a closure — it's never exposed on the DOM or -// any accessible object. vega-embed v7 also doesn't set __vega_embed__ on -// the element. The only code with access to the View is vega-embed's own -// action handlers, so we delegate to them. -function findVegaAction(container, extension) { - return container.querySelector( - '.vega-actions a[download$=".' + extension + '"]' - ); -} +/* Generated file. Source: js/src/viz-py.ts. Do not edit directly. */ -// Helper: find a widget container by its base ID. -// Shiny module namespacing may prefix the ID (e.g. "mod-querychat_viz_abc"), -// so we match elements whose ID ends with the base widget ID. -function findWidgetContainer(widgetId) { - return document.getElementById(widgetId) - || document.querySelector('[id$="' + CSS.escape(widgetId) + '"]'); -} - -// Helper: trigger a vega-embed export action link. -// vega-embed attaches an async mousedown handler that calls -// view.toImageURL() and sets the link's href to a data URL. -// We dispatch mousedown, then use a MutationObserver to detect -// when href changes from "#" to a data URL, and click the link. -function triggerVegaAction(link, filename) { - link.download = filename; - - // If href is already a data URL (unlikely but possible), click immediately. - if (link.href && link.href !== "#" && !link.href.endsWith("#")) { - link.click(); - return; +"use strict"; +(() => { + // src/viz-core.ts + function findWidgetContainer(widgetId) { + return document.getElementById(widgetId); } - - var observer = new MutationObserver(function () { + function findVegaAction(container, format) { + return container.querySelector( + `.vega-actions a[download$=".${format}"]` + ); + } + function triggerVegaAction(link, filename) { + link.download = filename; if (link.href && link.href !== "#" && !link.href.endsWith("#")) { - observer.disconnect(); - clearTimeout(timeout); link.click(); + return; } - }); - - observer.observe(link, { attributes: true, attributeFilter: ["href"] }); - - var timeout = setTimeout(function () { - observer.disconnect(); - console.error("Timed out waiting for vega-embed to generate image"); - }, 5000); - - link.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); -} - -function closeAllSaveMenus() { - document.querySelectorAll(".querychat-save-menu--visible").forEach(function (menu) { + const observer = new MutationObserver(() => { + if (link.href && link.href !== "#" && !link.href.endsWith("#")) { + observer.disconnect(); + clearTimeout(timeoutId); + link.click(); + } + }); + observer.observe(link, { + attributes: true, + attributeFilter: ["href"] + }); + const timeoutId = window.setTimeout(() => { + observer.disconnect(); + console.error("Timed out waiting for vega-embed to generate image"); + }, 5e3); + link.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); + } + var openSaveMenu = null; + function closeSaveMenu(menu) { menu.classList.remove("querychat-save-menu--visible"); - }); -} - -function handleShowQuery(event, btn) { - event.stopPropagation(); - var targetId = btn.dataset.target; - var section = document.getElementById(targetId); - if (!section) return; - var isVisible = section.classList.toggle("querychat-query-section--visible"); - var label = btn.querySelector(".querychat-query-label"); - var chevron = btn.querySelector(".querychat-query-chevron"); - if (label) label.textContent = isVisible ? "Hide Query" : "Show Query"; - if (chevron) chevron.classList.toggle("querychat-query-chevron--expanded", isVisible); -} - -function handleSaveToggle(event, btn) { - event.stopPropagation(); - var menu = btn.parentElement.querySelector(".querychat-save-menu"); - if (menu) menu.classList.toggle("querychat-save-menu--visible"); -} - -function handleSaveExport(event, btn, extension) { - event.stopPropagation(); - var widgetId = btn.dataset.widgetId; - var title = btn.dataset.title || "chart"; - var menu = btn.closest(".querychat-save-menu"); - if (menu) menu.classList.remove("querychat-save-menu--visible"); - - var container = findWidgetContainer(widgetId); - if (!container) return; - var link = findVegaAction(container, extension); - if (!link) return; - triggerVegaAction(link, title + "." + extension); -} - -function handleCopy(event, btn) { - event.stopPropagation(); - var query = btn.dataset.query; - if (!query) return; - navigator.clipboard.writeText(query).then(function () { - var original = btn.textContent; - btn.textContent = "Copied!"; - setTimeout(function () { btn.textContent = original; }, 2000); - }).catch(function (err) { - console.error("Failed to copy:", err); - }); -} - -// Single delegated click handler for all querychat viz footer buttons. -window.addEventListener("click", function (event) { - var target = event.target; - - var btn = target.closest(".querychat-show-query-btn"); - if (btn) { handleShowQuery(event, btn); return; } - - btn = target.closest(".querychat-save-png-btn"); - if (btn) { handleSaveExport(event, btn, "png"); return; } - - btn = target.closest(".querychat-save-svg-btn"); - if (btn) { handleSaveExport(event, btn, "svg"); return; } - - btn = target.closest(".querychat-copy-btn"); - if (btn) { handleCopy(event, btn); return; } - - btn = target.closest(".querychat-save-btn"); - if (btn) { handleSaveToggle(event, btn); return; } + if (openSaveMenu === menu) { + openSaveMenu = null; + } + } + function closeOpenSaveMenu() { + if (openSaveMenu) { + closeSaveMenu(openSaveMenu); + } + } + function handleShowQuery(event, button) { + event.stopPropagation(); + const targetId = button.dataset.target; + if (!targetId) { + return; + } + const section = document.getElementById(targetId); + if (!section) { + return; + } + const isVisible = section.classList.toggle("querychat-query-section--visible"); + const label = button.querySelector(".querychat-query-label"); + const chevron = button.querySelector(".querychat-query-chevron"); + if (label) { + label.textContent = isVisible ? "Hide Query" : "Show Query"; + } + if (chevron) { + chevron.classList.toggle("querychat-query-chevron--expanded", isVisible); + } + } + function handleSaveToggle(event, button) { + event.stopPropagation(); + const menu = button.parentElement?.querySelector( + ".querychat-save-menu" + ); + if (!menu) { + return; + } + if (openSaveMenu && openSaveMenu !== menu) { + closeSaveMenu(openSaveMenu); + } + if (menu.classList.contains("querychat-save-menu--visible")) { + closeSaveMenu(menu); + } else { + menu.classList.add("querychat-save-menu--visible"); + openSaveMenu = menu; + } + } + function handleSaveExport(event, button, format, adapter) { + event.stopPropagation(); + const widgetId = button.dataset.widgetId; + if (!widgetId) { + return; + } + const filename = button.dataset.title || "chart"; + const menu = button.closest(".querychat-save-menu"); + if (menu) { + closeSaveMenu(menu); + } + adapter.exportPlot(widgetId, format, filename); + } + function handleCopy(event, button) { + event.stopPropagation(); + const query = button.dataset.query; + if (!query) { + return; + } + navigator.clipboard.writeText(query).then(() => { + const original = button.textContent; + button.textContent = "Copied!"; + setTimeout(() => { + button.textContent = original; + }, 2e3); + }).catch((error) => { + console.error("Failed to copy:", error); + }); + } + function installVizFooter(adapter) { + window.addEventListener("click", (event) => { + const target = event.target; + if (!(target instanceof Element)) { + closeOpenSaveMenu(); + return; + } + const actionElement = target.closest("[data-querychat-action]"); + const action = actionElement?.dataset.querychatAction; + if (!action || !actionElement) { + closeOpenSaveMenu(); + return; + } + switch (action) { + case "show-query": + handleShowQuery(event, actionElement); + return; + case "save-toggle": + handleSaveToggle(event, actionElement); + return; + case "save-png": + handleSaveExport(event, actionElement, "png", adapter); + return; + case "save-svg": + handleSaveExport(event, actionElement, "svg", adapter); + return; + case "copy": + handleCopy(event, actionElement); + return; + } + }); + } + function createVegaActionAdapter() { + return { + exportPlot(widgetId, format, filename) { + const container = findWidgetContainer(widgetId); + if (!container) { + return; + } + const link = findVegaAction(container, format); + if (!link) { + return; + } + triggerVegaAction(link, `${filename}.${format}`); + } + }; + } - // Click outside any button — close open save menus - closeAllSaveMenus(); -}); + // src/viz-py.ts + installVizFooter(createVegaActionAdapter()); +})(); diff --git a/pkg-py/tests/playwright/test_11_viz_footer.py b/pkg-py/tests/playwright/test_11_viz_footer.py index 2cd58695..2e8e5e36 100644 --- a/pkg-py/tests/playwright/test_11_viz_footer.py +++ b/pkg-py/tests/playwright/test_11_viz_footer.py @@ -9,6 +9,7 @@ from __future__ import annotations +from pathlib import Path from typing import TYPE_CHECKING import pytest @@ -23,6 +24,19 @@ TOOL_RESULT_TIMEOUT = 90_000 +def _download_from_save_menu(page: Page, format: str): + """Open the save menu, click the requested format, and capture the download.""" + page.locator(".querychat-save-btn").click() + + option = page.locator(f".querychat-save-{format}-btn") + title = option.get_attribute("data-title") or "chart" + + with page.expect_download(timeout=30_000) as download_info: + option.click() + + return download_info.value, title + + @pytest.fixture(autouse=True) def _send_viz_prompt( page: Page, app_10_viz: str, chat_10_viz: ChatController @@ -152,3 +166,29 @@ def test_toggle_save_menu(self, page: Page) -> None: btn.click() expect(menu).not_to_have_class("querychat-save-menu--visible") + + def test_save_as_png_downloads_png(self, page: Page) -> None: + """Clicking 'Save as PNG' should download a PNG file.""" + download, title = _download_from_save_menu(page, "png") + + assert download.suggested_filename == f"{title}.png" + + download_path = download.path() + assert download_path is not None + + content = Path(download_path).read_bytes() + assert content.startswith(b"\x89PNG\r\n\x1a\n") + assert len(content) > 100 + + def test_save_as_svg_downloads_svg(self, page: Page) -> None: + """Clicking 'Save as SVG' should download an SVG file.""" + download, title = _download_from_save_menu(page, "svg") + + assert download.suggested_filename == f"{title}.svg" + + download_path = download.path() + assert download_path is not None + + content = Path(download_path).read_text(encoding="utf-8") + assert "" in content diff --git a/pkg-py/tests/test_viz_footer.py b/pkg-py/tests/test_viz_footer.py index 7051fec4..8074b827 100644 --- a/pkg-py/tests/test_viz_footer.py +++ b/pkg-py/tests/test_viz_footer.py @@ -107,3 +107,27 @@ def test_preload_markup_has_no_inline_script(self): assert "hidden" in rendered["html"] assert " .vega-actions) { display: none !important; diff --git a/pkg-r/inst/htmldep/viz.js b/pkg-r/inst/htmldep/viz.js index 85d01cd0..d2798028 100644 --- a/pkg-r/inst/htmldep/viz.js +++ b/pkg-r/inst/htmldep/viz.js @@ -1,117 +1,166 @@ -(function () { - // Helper: find the ggsql widget element by the output element ID used in - // R/Shiny. The current widget is rendered as , but older markup - // may still wrap a nested custom element. - function findGgsqlVizElement(widgetId) { - var container = document.getElementById(widgetId); - if (!container) return null; - var tagName = container.tagName && container.tagName.toLowerCase(); - if (tagName === "ggsql-vega" || tagName === "ggsql-viz") return container; - return container.querySelector("ggsql-vega, ggsql-viz"); - } - - // Helper: download a chart from a element using the Vega View API. - // stores the Vega View instance as `._view` after vegaEmbed renders. - function downloadFromView(vizEl, format, filename) { - if (!vizEl || !vizEl._view) return; - var view = vizEl._view; +/* Generated file. Source: js/src/viz-r.ts. Do not edit directly. */ - if (format === "png") { - view.toImageURL("png").then(function (url) { - triggerDownload(url, filename + ".png"); - }).catch(function (err) { - console.error("querychat: failed to export PNG:", err); - }); - } else if (format === "svg") { - view.toSVG().then(function (svg) { - var blob = new Blob([svg], { type: "image/svg+xml" }); - var url = URL.createObjectURL(blob); - triggerDownload(url, filename + ".svg"); - URL.revokeObjectURL(url); - }).catch(function (err) { - console.error("querychat: failed to export SVG:", err); - }); - } +"use strict"; +(() => { + // src/viz-core.ts + function findWidgetContainer(widgetId) { + return document.getElementById(widgetId); } - - function triggerDownload(url, filename) { - var a = document.createElement("a"); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); + function findVegaAction(container, format) { + return container.querySelector( + `.vega-actions a[download$=".${format}"]` + ); } - - function closeAllSaveMenus() { - document.querySelectorAll(".querychat-save-menu--visible").forEach(function (menu) { - menu.classList.remove("querychat-save-menu--visible"); + function triggerVegaAction(link, filename) { + link.download = filename; + if (link.href && link.href !== "#" && !link.href.endsWith("#")) { + link.click(); + return; + } + const observer = new MutationObserver(() => { + if (link.href && link.href !== "#" && !link.href.endsWith("#")) { + observer.disconnect(); + clearTimeout(timeoutId); + link.click(); + } + }); + observer.observe(link, { + attributes: true, + attributeFilter: ["href"] }); + const timeoutId = window.setTimeout(() => { + observer.disconnect(); + console.error("Timed out waiting for vega-embed to generate image"); + }, 5e3); + link.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); } - - function handleShowQuery(event, btn) { + var openSaveMenu = null; + function closeSaveMenu(menu) { + menu.classList.remove("querychat-save-menu--visible"); + if (openSaveMenu === menu) { + openSaveMenu = null; + } + } + function closeOpenSaveMenu() { + if (openSaveMenu) { + closeSaveMenu(openSaveMenu); + } + } + function handleShowQuery(event, button) { event.stopPropagation(); - var targetId = btn.dataset.target; - var section = document.getElementById(targetId); - if (!section) return; - var isVisible = section.classList.toggle("querychat-query-section--visible"); - var label = btn.querySelector(".querychat-query-label"); - var chevron = btn.querySelector(".querychat-query-chevron"); - if (label) label.textContent = isVisible ? "Hide Query" : "Show Query"; - if (chevron) chevron.classList.toggle("querychat-query-chevron--expanded", isVisible); + const targetId = button.dataset.target; + if (!targetId) { + return; + } + const section = document.getElementById(targetId); + if (!section) { + return; + } + const isVisible = section.classList.toggle("querychat-query-section--visible"); + const label = button.querySelector(".querychat-query-label"); + const chevron = button.querySelector(".querychat-query-chevron"); + if (label) { + label.textContent = isVisible ? "Hide Query" : "Show Query"; + } + if (chevron) { + chevron.classList.toggle("querychat-query-chevron--expanded", isVisible); + } } - - function handleSaveToggle(event, btn) { + function handleSaveToggle(event, button) { event.stopPropagation(); - var menu = btn.parentElement.querySelector(".querychat-save-menu"); - if (menu) menu.classList.toggle("querychat-save-menu--visible"); + const menu = button.parentElement?.querySelector( + ".querychat-save-menu" + ); + if (!menu) { + return; + } + if (openSaveMenu && openSaveMenu !== menu) { + closeSaveMenu(openSaveMenu); + } + if (menu.classList.contains("querychat-save-menu--visible")) { + closeSaveMenu(menu); + } else { + menu.classList.add("querychat-save-menu--visible"); + openSaveMenu = menu; + } } - - function handleSaveExport(event, btn, format) { + function handleSaveExport(event, button, format, adapter) { event.stopPropagation(); - var widgetId = btn.dataset.widgetId; - var title = btn.dataset.title || "chart"; - var menu = btn.closest(".querychat-save-menu"); - if (menu) menu.classList.remove("querychat-save-menu--visible"); - - var vizEl = findGgsqlVizElement(widgetId); - if (!vizEl) return; - downloadFromView(vizEl, format, title); + const widgetId = button.dataset.widgetId; + if (!widgetId) { + return; + } + const filename = button.dataset.title || "chart"; + const menu = button.closest(".querychat-save-menu"); + if (menu) { + closeSaveMenu(menu); + } + adapter.exportPlot(widgetId, format, filename); } - - function handleCopy(event, btn) { + function handleCopy(event, button) { event.stopPropagation(); - var query = btn.dataset.query; - if (!query) return; - navigator.clipboard.writeText(query).then(function () { - var original = btn.textContent; - btn.textContent = "Copied!"; - setTimeout(function () { btn.textContent = original; }, 2000); - }).catch(function (err) { - console.error("Failed to copy:", err); + const query = button.dataset.query; + if (!query) { + return; + } + navigator.clipboard.writeText(query).then(() => { + const original = button.textContent; + button.textContent = "Copied!"; + setTimeout(() => { + button.textContent = original; + }, 2e3); + }).catch((error) => { + console.error("Failed to copy:", error); }); } + function installVizFooter(adapter) { + window.addEventListener("click", (event) => { + const target = event.target; + if (!(target instanceof Element)) { + closeOpenSaveMenu(); + return; + } + const actionElement = target.closest("[data-querychat-action]"); + const action = actionElement?.dataset.querychatAction; + if (!action || !actionElement) { + closeOpenSaveMenu(); + return; + } + switch (action) { + case "show-query": + handleShowQuery(event, actionElement); + return; + case "save-toggle": + handleSaveToggle(event, actionElement); + return; + case "save-png": + handleSaveExport(event, actionElement, "png", adapter); + return; + case "save-svg": + handleSaveExport(event, actionElement, "svg", adapter); + return; + case "copy": + handleCopy(event, actionElement); + return; + } + }); + } + function createVegaActionAdapter() { + return { + exportPlot(widgetId, format, filename) { + const container = findWidgetContainer(widgetId); + if (!container) { + return; + } + const link = findVegaAction(container, format); + if (!link) { + return; + } + triggerVegaAction(link, `${filename}.${format}`); + } + }; + } - // Single delegated click handler for all querychat viz footer buttons. - window.addEventListener("click", function (event) { - var target = event.target; - - var btn = target.closest(".querychat-show-query-btn"); - if (btn) { handleShowQuery(event, btn); return; } - - btn = target.closest(".querychat-save-png-btn"); - if (btn) { handleSaveExport(event, btn, "png"); return; } - - btn = target.closest(".querychat-save-svg-btn"); - if (btn) { handleSaveExport(event, btn, "svg"); return; } - - btn = target.closest(".querychat-copy-btn"); - if (btn) { handleCopy(event, btn); return; } - - btn = target.closest(".querychat-save-btn"); - if (btn) { handleSaveToggle(event, btn); return; } - - // Click outside any button — close open save menus - closeAllSaveMenus(); - }); + // src/viz-r.ts + installVizFooter(createVegaActionAdapter()); })();