Data last updated on
and presented through
. ",
+ "navigationTarget": "_self"
+ },
+ "type": "map",
+ "color": "greenbluereverse",
+ "columns": {
+ "geo": {
+ "name": "State",
+ "label": "Location",
+ "tooltip": false,
+ "dataTable": true
+ },
+ "primary": {
+ "dataTable": true,
+ "tooltip": true,
+ "prefix": "",
+ "suffix": "",
+ "name": "activity_level_label",
+ "label": "Viral Activity Level",
+ "roundToPlace": 0
+ },
+ "navigate": {
+ "name": ""
+ },
+ "latitude": {
+ "name": ""
+ },
+ "longitude": {
+ "name": ""
+ },
+ "additionalColumn1": {
+ "label": "Sites Currently Reporting",
+ "dataTable": true,
+ "tooltips": false,
+ "prefix": "",
+ "suffix": "",
+ "name": "num_sites",
+ "tooltip": true
+ },
+ "additionalColumn2": {
+ "label": "Limited Coverage",
+ "dataTable": true,
+ "tooltips": false,
+ "prefix": "",
+ "suffix": "",
+ "tooltip": false,
+ "name": "hatch"
+ },
+ "additionalColumn3": {
+ "label": "",
+ "dataTable": false,
+ "tooltips": false,
+ "prefix": "",
+ "suffix": "",
+ "tooltip": true,
+ "useCommas": true,
+ "name": "hatch"
+ }
+ },
+ "legend": {
+ "descriptions": {},
+ "specialClasses": [],
+ "unified": false,
+ "singleColumn": false,
+ "singleRow": true,
+ "verticalSorted": false,
+ "showSpecialClassesLast": true,
+ "dynamicDescription": false,
+ "type": "category",
+ "numberOfItems": 8,
+ "position": "top",
+ "title": "Wastewater Viral Activity Level",
+ "categoryValuesOrder": ["Very Low", "Low", "Moderate", "High", "Very High", "No Data", "Minimal"],
+ "additionalCategories": ["No Data", "Very Low", "Low", "Moderate", "High", "Very High"],
+ "description": "",
+ "style": "gradient",
+ "subStyle": "linear blocks",
+ "tickRotation": "",
+ "singleColumnLegend": false,
+ "hideBorder": true
+ },
+ "filters": [
+ {
+ "order": "asc",
+ "label": "Select a virus:",
+ "columnName": "pathogen",
+ "values": ["COVID-19", "Influenza A", "RSV"],
+ "active": "COVID-19",
+ "filterStyle": "tab-simple",
+ "orderedValues": ["COVID-19", "Influenza A", "RSV"],
+ "setByQueryParameter": "virus"
+ }
+ ],
+ "table": {
+ "label": "Data Table",
+ "expanded": false,
+ "limitHeight": true,
+ "height": "500",
+ "caption": "",
+ "showDownloadUrl": false,
+ "showDataTableLink": true,
+ "showFullGeoNameInCSV": false,
+ "forceDisplay": true,
+ "download": true,
+ "indexLabel": "State/Territory",
+ "wrapColumns": false,
+ "showDownloadLinkBelow": true
+ },
+ "tooltips": {
+ "appearanceType": "hover",
+ "linkLabel": "Learn More",
+ "capitalizeLabels": true,
+ "opacity": 90
+ },
+ "visual": {
+ "minBubbleSize": 1,
+ "maxBubbleSize": 20,
+ "extraBubbleBorder": false,
+ "cityStyle": "circle",
+ "geoCodeCircleSize": 2,
+ "showBubbleZeros": false,
+ "cityStyleLabel": "",
+ "additionalCityStyles": []
+ },
+ "mapPosition": {
+ "coordinates": [0, 30],
+ "zoom": 1
+ },
+ "map": {
+ "layers": [],
+ "patterns": [
+ {
+ "dataKey": "hatch",
+ "pattern": "lines",
+ "dataValue": "Limited Coverage",
+ "label": "Limited Coverage*",
+ "size": "medium"
+ }
+ ]
+ },
+ "hexMap": {
+ "type": "",
+ "shapeGroups": [
+ {
+ "legendTitle": "",
+ "legendDescription": "",
+ "items": [
+ {
+ "key": "",
+ "shape": "Arrow Up",
+ "column": "",
+ "operator": "=",
+ "value": ""
+ }
+ ]
+ }
+ ]
+ },
+ "filterBehavior": "Filter Change",
+ "filterIntro": "",
+ "customColors": [
+ "#C8EFDA",
+ "#9FDAD0",
+ "#9FDAD0",
+ "#6BB0BD",
+ "#4B7F9B",
+ "#34547B",
+ "#B4B4B4",
+ "#B4B4B4",
+ "#B4B4B4",
+ "#B4B4B4"
+ ],
+ "datasets": {},
+ "dataFileName": "https://www.cdc.gov/wcms/vizdata/ncezid_didri/NWSSStateMapCombined.json",
+ "dataFileSourceType": "url",
+ "dataDescription": {
+ "horizontal": false,
+ "series": false
+ },
+ "version": "4.25.1",
+ "dataUrl": "https://www.cdc.gov/wcms/vizdata/ncezid_didri/NWSSStateMapCombined.json",
+ "migrations": {
+ "addColorMigration": true
+ }
+}
diff --git a/packages/embed/generator.html b/packages/embed/generator.html
new file mode 100644
index 0000000000..21b562015a
--- /dev/null
+++ b/packages/embed/generator.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
Embed CDC Visualization
+
+
+
+
+
+
+
diff --git a/packages/embed/index.html b/packages/embed/index.html
new file mode 100644
index 0000000000..5b5c27952f
--- /dev/null
+++ b/packages/embed/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
CDC Visualization
+
+
+
+
+
+
+
diff --git a/packages/embed/package.json b/packages/embed/package.json
new file mode 100644
index 0000000000..3f9ec7ffb9
--- /dev/null
+++ b/packages/embed/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "@cdc/embed",
+ "version": "4.25.10",
+ "description": "Embeddable COVE visualizations for partner websites",
+ "moduleName": "CdcEmbed",
+ "main": "dist/embed.html",
+ "type": "module",
+ "scripts": {
+ "start": "vite --open",
+ "build": "vite build",
+ "preview": "vite preview",
+ "test": "vitest run --reporter verbose",
+ "test-watch": "vitest watch --reporter verbose",
+ "test-watch:ui": "vitest --ui"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/CDCgov/cdc-open-viz",
+ "directory": "packages/embed"
+ },
+ "author": "CDC",
+ "license": "Apache-2.0",
+ "devDependencies": {
+ "@vitejs/plugin-react": "^4.3.4",
+ "vite": "^5.4.21"
+ },
+ "peerDependencies": {
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
+ }
+}
diff --git a/packages/embed/src/embed-helper/index.js b/packages/embed/src/embed-helper/index.js
new file mode 100644
index 0000000000..58fd42af6f
--- /dev/null
+++ b/packages/embed/src/embed-helper/index.js
@@ -0,0 +1,105 @@
+/**
+ * CDC COVE Embed Helper - Phase 2
+ *
+ * Standalone script that handles iframe resizing for embedded COVE visualizations.
+ * Partners include this on their page along with the iframe(s).
+ *
+ * Usage:
+ *
+ *
+ */
+
+import { isValidMessageOrigin } from '../shared/urlValidation'
+
+let iframeCounter = 0
+
+// Initialize an iframe with unique ID and event listener
+function initializeIframe(iframe) {
+ // Skip if already initialized
+ if (iframe.hasAttribute('data-cove-id')) {
+ return
+ }
+
+ const id = `cove-${iframeCounter++}`
+ iframe.setAttribute('data-cove-id', id)
+
+ // Send the ID to the iframe via postMessage
+ const sendId = () => {
+ if (iframe.contentWindow) {
+ iframe.contentWindow.postMessage(
+ {
+ type: 'cove:setId',
+ id: id
+ },
+ '*'
+ )
+ }
+ }
+
+ // If iframe is already loaded, send immediately
+ if (iframe.contentDocument && iframe.contentDocument.readyState === 'complete') {
+ sendId()
+ }
+
+ // Also listen for load event in case it hasn't loaded yet
+ iframe.addEventListener('load', sendId)
+}
+
+// Initialize existing iframes
+const existingIframes = document.querySelectorAll('iframe[data-cove-embed]')
+if (existingIframes.length > 0) {
+ existingIframes.forEach(initializeIframe)
+}
+
+// Watch for dynamically added iframes (for React/SPA apps)
+const observer = new MutationObserver(mutations => {
+ mutations.forEach(mutation => {
+ mutation.addedNodes.forEach(node => {
+ // Check if the added node is an iframe
+ if (node.nodeType === Node.ELEMENT_NODE) {
+ if (node.matches && node.matches('iframe[data-cove-embed]')) {
+ initializeIframe(node)
+ }
+ // Also check children in case a container was added
+ const iframes = node.querySelectorAll && node.querySelectorAll('iframe[data-cove-embed]')
+ if (iframes && iframes.length > 0) {
+ iframes.forEach(initializeIframe)
+ }
+ }
+ })
+ })
+})
+
+// Start observing the document for iframe additions
+observer.observe(document.body, {
+ childList: true,
+ subtree: true
+})
+
+// Listen for resize messages from embedded visualizations
+window.addEventListener('message', function (event) {
+ if (!isValidMessageOrigin(event.origin)) {
+ console.warn('Embed Helper: Rejected message from invalid origin:', event.origin)
+ return
+ }
+
+ // Handle resize events
+ if (event.data && event.data.type === 'cove:resize') {
+ const iframeId = event.data.id
+ const height = event.data.height
+
+ if (!height || typeof height !== 'number') {
+ console.warn('CDC COVE Embed Helper: Invalid height received:', height)
+ return
+ }
+
+ // Find the corresponding iframe
+ const iframe = document.querySelector(`iframe[data-cove-id="${iframeId}"]`)
+
+ if (iframe) {
+ iframe.style.height = height + 'px'
+ } else {
+ console.warn(`[Embed Helper] ✗ Could not find iframe with id "${iframeId}"`)
+ }
+ }
+})
diff --git a/packages/embed/src/embed-page/EmbedRenderer.tsx b/packages/embed/src/embed-page/EmbedRenderer.tsx
new file mode 100644
index 0000000000..05b3dbee2d
--- /dev/null
+++ b/packages/embed/src/embed-page/EmbedRenderer.tsx
@@ -0,0 +1,140 @@
+import React, { useEffect, useRef, useState } from 'react'
+import { useCoveContainer } from '../shared/useCoveContainer'
+import { getConfigUrlParam } from '../shared/urlValidation'
+
+/**
+ * EmbedRenderer - Phase 1 & 2
+ *
+ * Creates a COVE container div with proper data attributes.
+ * The production main.js (loaded in HTML) will find this div and render the visualization.
+ * Also sends resize events to parent window for iframe height adjustment.
+ */
+const EmbedRenderer: React.FC = () => {
+ const containerRef = useRef
(null)
+ const [iframeId, setIframeId] = useState(null)
+ const iframeIdRef = useRef(null)
+ const lastHeightRef = useRef(0) // Shared across all resize paths
+
+ const configUrl = getConfigUrlParam()
+
+ // Setup COVE container using shared hook
+ useCoveContainer(containerRef, configUrl)
+
+ // Measure height and send resize message (with duplicate detection)
+ const measureAndSend = (id: string) => {
+ const container = containerRef.current
+ if (!container) return
+
+ const height = Math.ceil(container.getBoundingClientRect().height) + 15
+
+ if (height < 100) return
+
+ // Check against shared lastHeight - prevents duplicates from any path
+ if (Math.abs(height - lastHeightRef.current) <= 1) {
+ return
+ }
+
+ lastHeightRef.current = height
+
+ const message = {
+ type: 'cove:resize',
+ height: height,
+ id: id
+ }
+
+ window.parent.postMessage(message, '*')
+ }
+
+ // Sync iframeIdRef with state
+ useEffect(() => {
+ iframeIdRef.current = iframeId
+ }, [iframeId])
+
+ // Listen for cove_loaded - send resize if we have an ID
+ useEffect(() => {
+ const handleCoveLoaded = () => {
+ if (iframeIdRef.current) {
+ measureAndSend(iframeIdRef.current)
+ }
+ }
+
+ document.addEventListener('cove_loaded', handleCoveLoaded)
+
+ return () => {
+ document.removeEventListener('cove_loaded', handleCoveLoaded)
+ }
+ }, [])
+
+ // Listen for ID assignment from parent embed-helper
+ useEffect(() => {
+ const handleMessage = (event: MessageEvent) => {
+ if (event.data && event.data.type === 'cove:setId') {
+ const newId = event.data.id
+ setIframeId(newId)
+ }
+ }
+
+ window.addEventListener('message', handleMessage)
+
+ return () => {
+ window.removeEventListener('message', handleMessage)
+ }
+ }, [])
+
+ // Phase 2: Send resize messages via ResizeObserver
+ // Only runs once we have an iframe ID
+ useEffect(() => {
+ if (!iframeId) {
+ return
+ }
+
+ let resizeTimeout: number
+
+ // Watch for content changes
+ const resizeObserver = new ResizeObserver(() => {
+ clearTimeout(resizeTimeout)
+ resizeTimeout = window.setTimeout(() => measureAndSend(iframeId), 10)
+ })
+
+ if (containerRef.current) {
+ resizeObserver.observe(containerRef.current)
+ }
+
+ // Send initial resize (in case content already rendered before ID arrived)
+ measureAndSend(iframeId)
+
+ return () => {
+ resizeObserver.disconnect()
+ clearTimeout(resizeTimeout)
+ }
+ }, [iframeId])
+
+ // Show error if no valid config URL provided
+ if (!configUrl) {
+ return (
+
+
Invalid Configuration
+
+ The configUrl parameter is missing or invalid.
+
+
+ Required: A relative URL must be provided.
+
+
+ Example: ?configUrl=/path/to/config.json
+
+
+ )
+ }
+
+ // Render the container div that COVE will populate
+ return
+}
+
+export default EmbedRenderer
diff --git a/packages/embed/src/embed-page/index.tsx b/packages/embed/src/embed-page/index.tsx
new file mode 100644
index 0000000000..9df9df8268
--- /dev/null
+++ b/packages/embed/src/embed-page/index.tsx
@@ -0,0 +1,14 @@
+import React, { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import EmbedRenderer from './EmbedRenderer'
+
+const container = document.getElementById('cove-embed-root')
+
+if (container) {
+ const root = createRoot(container)
+ root.render(
+
+
+
+ )
+}
diff --git a/packages/embed/src/generator/GeneratorApp.tsx b/packages/embed/src/generator/GeneratorApp.tsx
new file mode 100644
index 0000000000..414ba7097f
--- /dev/null
+++ b/packages/embed/src/generator/GeneratorApp.tsx
@@ -0,0 +1,179 @@
+import React, { useState, useMemo } from 'react'
+import { useConfigLoader } from '../shared/useConfigLoader'
+import { extractFilters, initializeFilterState, FilterState, allFiltersHaveQueryParam } from '../shared/filterUtils'
+import { getConfigUrlParam } from '../shared/urlValidation'
+import FilterCustomizationControls from './components/FilterCustomizationControls'
+import EmbedCodeGenerator from './components/EmbedCodeGenerator'
+import PreviewPanel from './components/PreviewPanel'
+
+/**
+ * COVE Embed Generator - Phase 5
+ * Allows partners to customize embed codes with filter defaults and hide options
+ *
+ * Layout:
+ * 1. Filter customization controls (source of truth)
+ * 2. Preview iframe
+ * 3. Generated embed code
+ */
+const GeneratorApp: React.FC = () => {
+ const title = 'CDC Visualization Embed Code'
+
+ const configUrl = getConfigUrlParam()
+
+ // Load config to extract filter metadata
+ const { loading, error, config } = useConfigLoader(configUrl)
+
+ // Extract filter metadata from config (memoize to prevent unnecessary re-renders)
+ const filters = React.useMemo(() => {
+ const extracted = extractFilters(config)
+ return extracted
+ }, [config])
+
+ // Check if all filters are valid (have setByQueryParameter)
+ const allValid = useMemo(() => allFiltersHaveQueryParam(filters), [filters])
+
+ // Check if there are no filters (simplified mode)
+ const hasNoFilters = filters.length === 0
+
+ // Initialize filter state from config defaults
+ const [filterState, setFilterState] = useState>({})
+
+ // Update filter state when filters change (only depends on filters, not filterState)
+ React.useEffect(() => {
+ if (filters.length > 0) {
+ const initialState = initializeFilterState(filters)
+ setFilterState(initialState)
+ }
+ }, [filters])
+
+ const handleFilterChange = (filterKey: string, value: string) => {
+ setFilterState(prev => {
+ const newState = {
+ ...prev,
+ [filterKey]: {
+ ...prev[filterKey],
+ value
+ }
+ }
+ return newState
+ })
+ }
+
+ const handleHideToggle = (filterKey: string, hide: boolean) => {
+ setFilterState(prev => {
+ const newState = {
+ ...prev,
+ [filterKey]: {
+ ...prev[filterKey],
+ hide
+ }
+ }
+ return newState
+ })
+ }
+
+ // No valid config URL provided
+ if (!configUrl) {
+ return (
+
+
{title}
+
+
Invalid Configuration
+
+ The configUrl parameter is missing or invalid.
+
+
+ Required: A relative URL must be provided.
+
+
+ Example: ?configUrl=/path/to/config.json
+
+
+
+ )
+ }
+
+ // Loading state
+ if (loading) {
+ return (
+
+
{title}
+
+
Loading configuration...
+
+
+ )
+ }
+
+ // Error state
+ if (error) {
+ return (
+
+
{title}
+
+
Configuration Error
+
{error}
+
+ Config URL: {configUrl}
+
+
+
+ )
+ }
+
+ return (
+
+
+
+ {/* Warning if some filters don't have setByQueryParameter */}
+ {!allValid && filters.length > 0 && (
+
+
+ ⚠️ Note: Some filters in this visualization cannot be customized. To fix this, open the
+ visualization in the COVE editor, set the "Query String Parameter" field for each filter, and save the
+ visualization.
+
+
+ )}
+
+ {/* 1. Filter Customization Controls (only show if there are filters) */}
+ {!hasNoFilters && (
+
+ )}
+
+ {/* Preview - section 1 if no filters, section 2 if filters */}
+
+
+ {/* Generated Embed Code - section 2 if no filters, section 3 if filters */}
+
+
+ )
+}
+
+export default GeneratorApp
diff --git a/packages/embed/src/generator/components/EmbedCodeGenerator.tsx b/packages/embed/src/generator/components/EmbedCodeGenerator.tsx
new file mode 100644
index 0000000000..1d38ac4309
--- /dev/null
+++ b/packages/embed/src/generator/components/EmbedCodeGenerator.tsx
@@ -0,0 +1,85 @@
+import React, { useState } from 'react'
+import { FilterMetadata, FilterState, buildFilterUrlParams } from '../../shared/filterUtils'
+import { generateEmbedCode } from '@cdc/core/helpers/embedCodeGenerator'
+
+type EmbedCodeGeneratorProps = {
+ configUrl: string
+ filters: FilterMetadata[]
+ filterState: Record
+ sectionNumber?: number
+}
+
+/**
+ * Displays the generated embed code with copy functionality
+ */
+const EmbedCodeGenerator: React.FC = ({
+ configUrl,
+ filters,
+ filterState,
+ sectionNumber = 3
+}) => {
+ const [copied, setCopied] = useState(false)
+
+ // Build URL parameters from filter state
+ const urlParams = buildFilterUrlParams(filters, filterState)
+
+ const embedCode = generateEmbedCode({
+ configUrl,
+ urlParams,
+ height: '400'
+ })
+
+ const handleCopy = async () => {
+ try {
+ await navigator.clipboard.writeText(embedCode)
+ setCopied(true)
+ setTimeout(() => setCopied(false), 3000)
+ } catch (err) {
+ console.error('Failed to copy:', err)
+ alert('Failed to copy automatically. Please copy the code manually.')
+ }
+ }
+
+ return (
+
+ {sectionNumber}. Copy Embed Code
+
+ Copy your embed code, then paste it into your website where you want the visualization to appear.
+
+
+
+
+ {copied ? '✓ Copied!' : 'Copy Code'}
+
+
+
+
+ {embedCode}
+
+
+ )
+}
+
+export default EmbedCodeGenerator
diff --git a/packages/embed/src/generator/components/FilterCustomizationControls.tsx b/packages/embed/src/generator/components/FilterCustomizationControls.tsx
new file mode 100644
index 0000000000..cf385eaeb4
--- /dev/null
+++ b/packages/embed/src/generator/components/FilterCustomizationControls.tsx
@@ -0,0 +1,138 @@
+import React from 'react'
+import { FilterMetadata, FilterState } from '../../shared/filterUtils'
+
+type FilterCustomizationControlsProps = {
+ filters: FilterMetadata[]
+ filterState: Record
+ onFilterChange: (filterKey: string, value: string) => void
+ onHideToggle: (filterKey: string, hide: boolean) => void
+}
+
+/**
+ * Custom filter controls for setting embed defaults and hide options
+ * These are the source of truth for the embed code generation
+ */
+const FilterCustomizationControls: React.FC = ({
+ filters,
+ filterState,
+ onFilterChange,
+ onHideToggle
+}) => {
+ // Component should not be rendered if there are no filters
+ // (handled by parent), but just in case, return null
+ if (filters.length === 0) {
+ return null
+ }
+
+ return (
+
+ 1. Customize {filters.length === 1 ? 'Filter' : 'Filters'}
+
+ Set default value{filters.length === 1 ? '' : 's'} and visibility for the{' '}
+ {filters.length === 1 ? 'filter' : 'filters'} in your embedded visualization.
+
+
+
+ {filters.map((filter, index) => {
+ const state = filterState[filter.key] || { value: '', hide: false }
+ const hasValues = filter.values && filter.values.length > 0
+ const isDisabled = !filter.setByQueryParameter
+
+ return (
+
+
+
+ {filter.label}
+ {isDisabled && (
+
+ (Cannot be set)
+
+ )}
+
+
+ {hasValues ? (
+
onFilterChange(filter.key, e.target.value)}
+ disabled={isDisabled}
+ style={{
+ width: '100%',
+ padding: '0.5rem 2rem 0.5rem 0.75rem',
+ fontSize: '0.9rem',
+ border: '2px solid #d1d5db',
+ borderRadius: '6px',
+ backgroundColor: isDisabled ? '#f3f4f6' : '#f9fafb',
+ cursor: isDisabled ? 'not-allowed' : 'pointer',
+ outline: 'none',
+ transition: 'border-color 0.15s ease-in-out',
+ backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 9L1 4h10z'/%3E%3C/svg%3E")`,
+ backgroundRepeat: 'no-repeat',
+ backgroundPosition: 'right 0.5rem center',
+ appearance: 'none',
+ WebkitAppearance: 'none',
+ MozAppearance: 'none'
+ }}
+ onFocus={e => {
+ if (!isDisabled) e.target.style.borderColor = '#3b82f6'
+ }}
+ onBlur={e => {
+ e.target.style.borderColor = '#d1d5db'
+ }}
+ >
+ {filter.values?.map((value, valueIndex) => (
+
+ {value}
+
+ ))}
+
+ ) : (
+
No values available
+ )}
+
+
+
+
+ onHideToggle(filter.key, e.target.checked)}
+ disabled={isDisabled}
+ style={{ marginRight: '0.5rem' }}
+ />
+ Hide filter in embed
+
+
+
+ )
+ })}
+
+
+ )
+}
+
+export default FilterCustomizationControls
diff --git a/packages/embed/src/generator/components/PreviewPanel.tsx b/packages/embed/src/generator/components/PreviewPanel.tsx
new file mode 100644
index 0000000000..718571c215
--- /dev/null
+++ b/packages/embed/src/generator/components/PreviewPanel.tsx
@@ -0,0 +1,69 @@
+import React from 'react'
+import { FilterMetadata, FilterState, buildFilterUrlParams } from '../../shared/filterUtils'
+import { getEmbedPath } from '@cdc/core/helpers/embedCodeGenerator'
+
+type PreviewPanelProps = {
+ configUrl: string
+ filters: FilterMetadata[]
+ filterState: Record
+ sectionNumber?: number
+ showSelectedSettings?: boolean
+}
+
+/**
+ * Live preview of the embed using an iframe
+ * Shows the actual embed experience with current filter settings
+ * NOTE: iframe attributes must match exactly what generateEmbedCode() produces
+ * Uses relative path (getEmbedPath) since preview is on same origin
+ */
+const PreviewPanel: React.FC = ({
+ configUrl,
+ filters,
+ filterState,
+ sectionNumber = 2,
+ showSelectedSettings = true
+}) => {
+ // Build URL parameters from filter state (same as EmbedCodeGenerator)
+ const urlParams = buildFilterUrlParams(filters, filterState)
+
+ // Build iframe URL using relative path (same origin)
+ const params = new URLSearchParams()
+ params.set('configUrl', configUrl)
+ Object.entries(urlParams).forEach(([key, value]) => {
+ if (value) params.set(key, value)
+ })
+ const embedUrl = `${getEmbedPath()}?${params.toString()}`
+
+ return (
+
+ {sectionNumber}. Preview
+
+ This shows how the visualization will appear on your website
+ {showSelectedSettings && ' with your selected settings'}.
+
+
+
+ {/* iframe attributes must match generateEmbedCode() exactly */}
+
+
+
+ )
+}
+
+export default PreviewPanel
diff --git a/packages/embed/src/generator/index.tsx b/packages/embed/src/generator/index.tsx
new file mode 100644
index 0000000000..adc88379c5
--- /dev/null
+++ b/packages/embed/src/generator/index.tsx
@@ -0,0 +1,15 @@
+import React, { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import GeneratorApp from './GeneratorApp'
+import '../embed-helper/index.js'
+
+const container = document.getElementById('generator-root')
+
+if (container) {
+ const root = createRoot(container)
+ root.render(
+
+
+
+ )
+}
diff --git a/packages/embed/src/shared/filterUtils.ts b/packages/embed/src/shared/filterUtils.ts
new file mode 100644
index 0000000000..1b75ea0ae2
--- /dev/null
+++ b/packages/embed/src/shared/filterUtils.ts
@@ -0,0 +1,132 @@
+/**
+ * Utilities for working with COVE filters
+ */
+
+import { CoveConfig } from './useConfigLoader'
+
+export type FilterMetadata = {
+ label: string
+ key: string
+ setByQueryParameter?: string
+ values?: any[]
+ defaultValue?: string
+ active?: string
+}
+
+export type FilterState = {
+ value: string
+ hide: boolean
+}
+
+/**
+ * Extract filter metadata from a COVE config
+ * Handles both regular viz filters and dashboard shared filters
+ */
+export function extractFilters(config: CoveConfig | null): FilterMetadata[] {
+ if (!config) return []
+
+ // Try regular filters first (charts, maps, etc.)
+ if (config.filters && Array.isArray(config.filters) && config.filters.length > 0) {
+ return config.filters.map(filter => normalizeFilter(filter))
+ }
+
+ // Try dashboard shared filters
+ if (config.dashboard?.sharedFilters && Array.isArray(config.dashboard.sharedFilters)) {
+ return config.dashboard.sharedFilters.map(filter => normalizeFilter(filter))
+ }
+
+ return []
+}
+
+/**
+ * Normalize a filter object to consistent metadata format
+ *
+ * Different filter types (chart filters vs dashboard filters) may use different field names.
+ * This function provides fallbacks to handle these variations:
+ *
+ * - label: Priority for readability: label > setByQueryParameter > columnName
+ * - key: May be called key, columnName, or id depending on the viz type
+ * - setByQueryParameter: MUST be used exactly as provided. COVE only recognizes this specific
+ * field for URL parameters - there are no fallbacks. Filters without this field cannot be
+ * controlled via URL parameters.
+ */
+function normalizeFilter(filter: any): FilterMetadata {
+ const normalized = {
+ label: filter.label || filter.setByQueryParameter || filter.columnName || 'Unnamed Filter',
+ key: filter.key || filter.columnName || String(filter.id) || '',
+ setByQueryParameter: filter.setByQueryParameter,
+ values: filter.values || [],
+ defaultValue: filter.defaultValue,
+ active: filter.active
+ }
+ return normalized
+}
+
+/**
+ * Get initial/default value for a filter
+ */
+export function getDefaultFilterValue(filter: FilterMetadata): string {
+ if (filter.defaultValue) return filter.defaultValue
+ if (filter.active) return filter.active
+ if (filter.values && filter.values.length > 0) return filter.values[0]
+ return ''
+}
+
+/**
+ * Initialize filter state from filter metadata
+ */
+export function initializeFilterState(filters: FilterMetadata[]): Record {
+ const state: Record = {}
+
+ filters.forEach(filter => {
+ state[filter.key] = {
+ value: getDefaultFilterValue(filter),
+ hide: false
+ }
+ })
+
+ return state
+}
+
+/**
+ * Check if all filters have setByQueryParameter defined
+ * Returns true if all filters can be controlled via URL, or if there are no filters
+ */
+export function allFiltersHaveQueryParam(filters: FilterMetadata[]): boolean {
+ if (filters.length === 0) return true
+ return filters.every(filter => !!filter.setByQueryParameter)
+}
+
+/**
+ * Build URL parameters from filter state
+ * Returns an object with URL parameters for both filter values and hide states
+ */
+export function buildFilterUrlParams(
+ filters: FilterMetadata[],
+ filterState: Record
+): Record {
+ const urlParams: Record = {}
+
+ filters.forEach(filter => {
+ if (!filter.setByQueryParameter) {
+ return
+ }
+
+ const state = filterState[filter.key]
+ if (!state) {
+ return
+ }
+
+ // Add filter value
+ if (state.value) {
+ urlParams[filter.setByQueryParameter] = state.value
+ }
+
+ // Add hide parameter
+ if (state.hide) {
+ urlParams[`hide${filter.setByQueryParameter}`] = 'true'
+ }
+ })
+
+ return urlParams
+}
diff --git a/packages/embed/src/shared/urlValidation.ts b/packages/embed/src/shared/urlValidation.ts
new file mode 100644
index 0000000000..bdd962e689
--- /dev/null
+++ b/packages/embed/src/shared/urlValidation.ts
@@ -0,0 +1,120 @@
+/**
+ * URL validation utilities for the embed package
+ *
+ */
+
+/**
+ * Gets and validates the configUrl parameter from the current URL
+ * Returns the validated configUrl if it's a valid relative URL, null otherwise
+ *
+ * @returns The validated configUrl or null if missing/invalid
+ *
+ * @example
+ * const configUrl = getConfigUrlParam()
+ * if (!configUrl) {
+ * // Show error - missing or invalid
+ * }
+ */
+export function getConfigUrlParam(): string | null {
+ const params = new URLSearchParams(window.location.search)
+ const configUrl = params.get('configUrl')
+
+ if (!configUrl) {
+ return null
+ }
+
+ // Validate that it's a relative URL
+ if (!isValidConfigUrl(configUrl)) {
+ return null
+ }
+
+ return configUrl
+}
+
+/**
+ * Validates that a configUrl is a relative URL (no protocol or host)
+ *
+ * Only relative URLs are allowed to ensure configs
+ * are loaded from the same origin as the embed page.
+ *
+ * @param configUrl - The URL to validate
+ * @returns true if the URL is valid (relative only), false otherwise
+ *
+ * @example
+ * isValidConfigUrl('/path/to/config.json') // true
+ * isValidConfigUrl('../config.json') // true
+ * isValidConfigUrl('config.json') // true
+ * isValidConfigUrl('https://evil.com/config.json') // false
+ * isValidConfigUrl('//evil.com/config.json') // false
+ * isValidConfigUrl('http://localhost/config.json') // false
+ */
+export function isValidConfigUrl(configUrl: string | null): boolean {
+ if (!configUrl || typeof configUrl !== 'string') {
+ return false
+ }
+
+ const trimmed = configUrl.trim()
+
+ if (trimmed.length === 0) {
+ return false
+ }
+
+ // Reject any URL that contains a protocol (http://, https://, ftp://, etc.)
+ if (trimmed.match(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//)) {
+ return false
+ }
+
+ // Reject protocol-relative URLs (//example.com/path)
+ if (trimmed.startsWith('//')) {
+ return false
+ }
+
+ // Reject URLs with protocols but no slashes (javascript:, data:, etc.)
+ if (trimmed.match(/^[a-zA-Z][a-zA-Z0-9+.-]*:/)) {
+ return false
+ }
+
+ // Additional validation: Try to parse it as a URL relative to current origin
+ try {
+ const parsed = new URL(trimmed, window.location.origin)
+
+ // Verify it's same origin
+ if (parsed.origin !== window.location.origin) {
+ return false
+ }
+
+ return true
+ } catch (error) {
+ return false
+ }
+}
+
+/**
+ * Validates a postMessage origin against allowed CDC domains
+ *
+ * @param origin - The origin to validate (from MessageEvent.origin)
+ * @returns true if the origin is allowed, false otherwise
+ */
+export function isValidMessageOrigin(origin: string): boolean {
+ if (!origin || typeof origin !== 'string') {
+ return false
+ }
+
+ try {
+ const url = new URL(origin)
+
+ // Allow localhost for development (HTTP only)
+ if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') {
+ return url.protocol === 'http:' || url.protocol === 'https:'
+ }
+
+ // Allow cdc.gov and any subdomain (HTTPS only)
+ if (url.hostname === 'cdc.gov' || url.hostname.endsWith('.cdc.gov')) {
+ return url.protocol === 'https:'
+ }
+
+ return false
+ } catch (error) {
+ return false
+ }
+}
diff --git a/packages/embed/src/shared/useConfigLoader.ts b/packages/embed/src/shared/useConfigLoader.ts
new file mode 100644
index 0000000000..45d3872c6b
--- /dev/null
+++ b/packages/embed/src/shared/useConfigLoader.ts
@@ -0,0 +1,86 @@
+import { useState, useEffect } from 'react'
+import { isValidConfigUrl } from './urlValidation'
+
+/**
+ * Minimal config type for generator
+ * Only includes properties needed for filter extraction
+ */
+export type CoveConfig = {
+ type?: string
+ filters?: any[]
+ dashboard?: {
+ sharedFilters?: any[]
+ }
+ [key: string]: any
+}
+
+/**
+ * State type for config loading
+ */
+export type ConfigLoadState = {
+ loading: boolean
+ error: string | null
+ config: CoveConfig | null
+}
+
+/**
+ * Load and parse a COVE configuration file
+ */
+async function loadConfig(configUrl: string): Promise {
+ if (!isValidConfigUrl(configUrl)) {
+ throw new Error('Invalid configUrl: must be a relative URL (no protocol or host)')
+ }
+
+ const response = await fetch(configUrl)
+
+ if (!response.ok) {
+ throw new Error(`Failed to load config: ${response.status} ${response.statusText}`)
+ }
+
+ const config = await response.json()
+ return config
+}
+
+/**
+ * Hook to load and manage COVE config state
+ */
+export function useConfigLoader(configUrl: string | null): ConfigLoadState {
+ const [state, setState] = useState({
+ loading: false,
+ error: null,
+ config: null
+ })
+
+ useEffect(() => {
+ if (!configUrl) {
+ setState({ loading: false, error: null, config: null })
+ return
+ }
+
+ let cancelled = false
+
+ setState({ loading: true, error: null, config: null })
+
+ loadConfig(configUrl)
+ .then(config => {
+ if (!cancelled) {
+ setState({ loading: false, error: null, config })
+ }
+ })
+ .catch(error => {
+ if (!cancelled) {
+ setState({
+ loading: false,
+ error: error.message || 'Failed to load configuration',
+ config: null
+ })
+ }
+ })
+
+ return () => {
+ cancelled = true
+ }
+ }, [configUrl])
+
+ return state
+}
diff --git a/packages/embed/src/shared/useCoveContainer.ts b/packages/embed/src/shared/useCoveContainer.ts
new file mode 100644
index 0000000000..21eb90246b
--- /dev/null
+++ b/packages/embed/src/shared/useCoveContainer.ts
@@ -0,0 +1,31 @@
+import { useEffect, RefObject } from 'react'
+import { isValidConfigUrl } from './urlValidation'
+
+/**
+ * Shared hook for setting up a COVE container div
+ * Used by both embed page and generator for consistent COVE rendering
+ */
+export function useCoveContainer(containerRef: RefObject, configUrl: string | null) {
+ useEffect(() => {
+ if (!containerRef.current || !configUrl) return
+
+ if (!isValidConfigUrl(configUrl)) {
+ console.error('Invalid configUrl: must be a relative URL', configUrl)
+ return
+ }
+
+ // Set data attributes that COVE wrapper expects
+ containerRef.current.setAttribute('class', 'wcms-viz-container')
+ containerRef.current.setAttribute('data-language', 'en')
+ containerRef.current.setAttribute('data-host', 'www.cdc.gov')
+ containerRef.current.setAttribute('data-config-url', configUrl)
+ containerRef.current.setAttribute('data-sid', '')
+
+ // Trigger COVE to load the visualization
+ // Check if CDC_Load_Viz function exists (from main.js)
+ // If main.js hasn't loaded yet, it will auto-detect on DOMContentLoaded
+ if (typeof (window as any).CDC_Load_Viz === 'function') {
+ ;(window as any).CDC_Load_Viz()
+ }
+ }, [configUrl])
+}
diff --git a/packages/embed/src/tests/embed.test.ts b/packages/embed/src/tests/embed.test.ts
new file mode 100644
index 0000000000..0fe6e6419a
--- /dev/null
+++ b/packages/embed/src/tests/embed.test.ts
@@ -0,0 +1,169 @@
+// embed.test.ts
+
+import { expect, describe, it } from 'vitest'
+import { isValidMessageOrigin } from '../shared/urlValidation'
+
+describe('URL Validation', () => {
+ describe('configUrl validation logic', () => {
+ function testConfigUrlPattern(url: any): boolean {
+ if (!url || typeof url !== 'string') {
+ return false
+ }
+
+ const trimmed = url.trim()
+
+ if (trimmed.length === 0) {
+ return false
+ }
+
+ // Reject any URL that contains a protocol (http://, https://, ftp://, etc.)
+ if (trimmed.match(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//)) {
+ return false
+ }
+
+ // Reject protocol-relative URLs (//example.com/path)
+ if (trimmed.startsWith('//')) {
+ return false
+ }
+
+ // Reject URLs with protocols but no slashes (javascript:, data:, etc.)
+ if (trimmed.match(/^[a-zA-Z][a-zA-Z0-9+.-]*:/)) {
+ return false
+ }
+
+ // If it passes all checks, it's a relative URL
+ return true
+ }
+
+ describe('should accept valid relative URLs', () => {
+ it('accepts absolute paths starting with /', () => {
+ expect(testConfigUrlPattern('/path/to/config.json')).toBe(true)
+ })
+
+ it('accepts relative paths', () => {
+ expect(testConfigUrlPattern('config.json')).toBe(true)
+ expect(testConfigUrlPattern('./config.json')).toBe(true)
+ expect(testConfigUrlPattern('../config.json')).toBe(true)
+ })
+
+ it('accepts paths with query parameters', () => {
+ expect(testConfigUrlPattern('/config.json?version=1')).toBe(true)
+ })
+
+ it('accepts paths with multiple directory levels', () => {
+ expect(testConfigUrlPattern('/data/visualizations/config.json')).toBe(true)
+ })
+ })
+
+ describe('should reject URLs with protocols', () => {
+ it('rejects https URLs', () => {
+ expect(testConfigUrlPattern('https://www.cdc.gov/config.json')).toBe(false)
+ expect(testConfigUrlPattern('https://evil.com/config.json')).toBe(false)
+ })
+
+ it('rejects http URLs', () => {
+ expect(testConfigUrlPattern('http://www.cdc.gov/config.json')).toBe(false)
+ expect(testConfigUrlPattern('http://localhost/config.json')).toBe(false)
+ })
+
+ it('rejects protocol-relative URLs', () => {
+ expect(testConfigUrlPattern('//www.cdc.gov/config.json')).toBe(false)
+ expect(testConfigUrlPattern('//evil.com/config.json')).toBe(false)
+ })
+
+ it('rejects dangerous protocols', () => {
+ expect(testConfigUrlPattern('javascript:alert(1)')).toBe(false)
+ expect(testConfigUrlPattern('data:text/html,')).toBe(false)
+ expect(testConfigUrlPattern('file:///etc/passwd')).toBe(false)
+ })
+ })
+
+ describe('should handle edge cases', () => {
+ it('rejects null and undefined', () => {
+ expect(testConfigUrlPattern(null)).toBe(false)
+ expect(testConfigUrlPattern(undefined)).toBe(false)
+ })
+
+ it('rejects empty strings', () => {
+ expect(testConfigUrlPattern('')).toBe(false)
+ expect(testConfigUrlPattern(' ')).toBe(false)
+ })
+
+ it('rejects non-string values', () => {
+ expect(testConfigUrlPattern(123)).toBe(false)
+ expect(testConfigUrlPattern({})).toBe(false)
+ expect(testConfigUrlPattern([])).toBe(false)
+ })
+ })
+ })
+
+ describe('isValidMessageOrigin', () => {
+ describe('should accept valid CDC origins', () => {
+ it('accepts cdc.gov with HTTPS', () => {
+ expect(isValidMessageOrigin('https://cdc.gov')).toBe(true)
+ })
+
+ it('accepts www.cdc.gov with HTTPS', () => {
+ expect(isValidMessageOrigin('https://www.cdc.gov')).toBe(true)
+ })
+
+ it('accepts CDC subdomains with HTTPS', () => {
+ expect(isValidMessageOrigin('https://wcms-wp-test.cdc.gov')).toBe(true)
+ expect(isValidMessageOrigin('https://data.cdc.gov')).toBe(true)
+ })
+
+ it('handles case insensitivity correctly', () => {
+ // URL parsing automatically lowercases hostname
+ expect(isValidMessageOrigin('https://WWW.CDC.GOV')).toBe(true)
+ expect(isValidMessageOrigin('https://Www.Cdc.Gov')).toBe(true)
+ })
+ })
+
+ describe('should accept localhost for development', () => {
+ it('accepts localhost with HTTP', () => {
+ expect(isValidMessageOrigin('http://localhost')).toBe(true)
+ })
+
+ it('accepts localhost with HTTPS', () => {
+ expect(isValidMessageOrigin('https://localhost')).toBe(true)
+ })
+
+ it('accepts localhost with ports', () => {
+ expect(isValidMessageOrigin('http://localhost:8080')).toBe(true)
+ expect(isValidMessageOrigin('http://localhost:3000')).toBe(true)
+ })
+
+ it('accepts 127.0.0.1 with HTTP', () => {
+ expect(isValidMessageOrigin('http://127.0.0.1')).toBe(true)
+ expect(isValidMessageOrigin('http://127.0.0.1:8080')).toBe(true)
+ })
+ })
+
+ describe('should reject invalid origins', () => {
+ it('rejects HTTP for cdc.gov (requires HTTPS)', () => {
+ expect(isValidMessageOrigin('http://www.cdc.gov')).toBe(false)
+ expect(isValidMessageOrigin('http://cdc.gov')).toBe(false)
+ })
+
+ it('rejects non-CDC domains', () => {
+ expect(isValidMessageOrigin('https://evil.com')).toBe(false)
+ expect(isValidMessageOrigin('https://cdc.gov.evil.com')).toBe(false)
+ expect(isValidMessageOrigin('https://notcdc.gov')).toBe(false)
+ })
+
+ it('rejects malformed origins', () => {
+ expect(isValidMessageOrigin('not-a-url')).toBe(false)
+ expect(isValidMessageOrigin('javascript:alert(1)')).toBe(false)
+ })
+
+ it('rejects null and undefined', () => {
+ expect(isValidMessageOrigin(null as any)).toBe(false)
+ expect(isValidMessageOrigin(undefined as any)).toBe(false)
+ })
+
+ it('rejects empty strings', () => {
+ expect(isValidMessageOrigin('')).toBe(false)
+ })
+ })
+ })
+})
diff --git a/packages/embed/vite.config.js b/packages/embed/vite.config.js
new file mode 100644
index 0000000000..97df651d68
--- /dev/null
+++ b/packages/embed/vite.config.js
@@ -0,0 +1,46 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+import { resolve } from 'path'
+
+// For multi-page setup, we need custom config instead of generateViteConfig
+// since that's designed for single-entry library builds
+
+export default defineConfig(({ mode }) => ({
+ plugins: [react()],
+
+ base: mode === 'production' ? '/TemplatePackage/contrib/widgets/openVizWrapper/dist/embed/' : '/',
+
+ server: {
+ port: 8080,
+ proxy: {
+ // Proxy /TemplatePackage requests to production cdc.gov
+ // This allows loading main.js chunks in dev mode
+ '/TemplatePackage': {
+ target: 'https://www.cdc.gov',
+ changeOrigin: true,
+ secure: true
+ }
+ }
+ },
+
+ build: {
+ ...(mode === 'production'
+ ? {
+ rollupOptions: {
+ input: {
+ embed: resolve(__dirname, 'index.html'),
+ helper: resolve(__dirname, 'src/embed-helper/index.js')
+ },
+ output: {
+ entryFileNames: chunkInfo => {
+ if (chunkInfo.name === 'helper') {
+ return 'embed-helper.js'
+ }
+ return '[name]-[hash].js'
+ }
+ }
+ }
+ }
+ : {})
+ }
+}))
diff --git a/packages/filtered-text/index.html b/packages/filtered-text/index.html
index 60efb769ea..979264351f 100644
--- a/packages/filtered-text/index.html
+++ b/packages/filtered-text/index.html
@@ -1,36 +1,24 @@
+
+
+
+
+
+
- .cdc-map-outer-container {
- min-height: 100vh;
- }
-
-
-
-
-
-
-
-
-
-
-
-
-
-