diff --git a/frontend/src/app/data-viewer/FieldLabel.tsx b/frontend/src/app/data-viewer/FieldLabel.tsx new file mode 100644 index 0000000..1f2b315 --- /dev/null +++ b/frontend/src/app/data-viewer/FieldLabel.tsx @@ -0,0 +1,25 @@ +import { FONT_MONO } from './theme'; +import { Text } from '@mantine/core'; + +export function FieldLabel({ + children, + small, +}: { + children: React.ReactNode; + small?: boolean; +}) { + return ( + + {children} + + ); +} diff --git a/frontend/src/app/data-viewer/MetricsPanels.tsx b/frontend/src/app/data-viewer/MetricsPanels.tsx new file mode 100644 index 0000000..5ea50e0 --- /dev/null +++ b/frontend/src/app/data-viewer/MetricsPanels.tsx @@ -0,0 +1,48 @@ +import { FONT_DISPLAY, COLOR } from './theme'; +import { FieldLabel } from './FieldLabel'; + +import { Grid, Text } from '@mantine/core'; + +const METRICS: { label: string; field: string; prefix?: string }[] = [ + { label: 'Population', field: 'Population (ACS)' }, + { + label: 'Household Income', + field: 'Median Household Income', + prefix: '$', + }, + { label: 'Median Age', field: 'Median Age' }, + { + label: 'In Labor Force (16+)', + field: 'Labor Force Participation Rate (16+)', + }, + { label: 'Median Home Value', field: 'Median Home Value', prefix: '$' }, +]; + +const metricValueStyle = { + fontFamily: FONT_DISPLAY, + fontSize: '1.9rem', + fontWeight: 600, + lineHeight: 1, + color: COLOR.birch, + marginBottom: 8, +}; + +const formatNumber = (v?: number) => + v === undefined || v === null ? '—' : v.toLocaleString(); + +export function MetricsPanel({ metrics }: { metrics: Record }) { + return ( + + {METRICS.map(({ label, field, prefix }) => ( + + s + + {prefix} + {formatNumber(metrics[field])} + + {label} + + ))} + + ); +} diff --git a/frontend/src/app/data-viewer/Tabs.module.css b/frontend/src/app/data-viewer/Tabs.module.css index d54fb67..032cac8 100644 --- a/frontend/src/app/data-viewer/Tabs.module.css +++ b/frontend/src/app/data-viewer/Tabs.module.css @@ -1,4 +1,4 @@ .tab:hover { background-color: white; - color: #143460; -} \ No newline at end of file + color: #122820; +} diff --git a/frontend/src/app/data-viewer/page.tsx b/frontend/src/app/data-viewer/page.tsx index 284e4df..e101df8 100644 --- a/frontend/src/app/data-viewer/page.tsx +++ b/frontend/src/app/data-viewer/page.tsx @@ -1,7 +1,25 @@ 'use client'; -import { Box, Grid, Text, Title, Tabs, ScrollArea } from '@mantine/core'; -import { HouseLineIcon, UserListIcon, TreeIcon, GraduationCapIcon, TrendUpIcon, Icon } from '@phosphor-icons/react'; +import { + Box, + Grid, + Text, + Title, + Tabs, + Container, + Group, + Button, + Stack, +} from '@mantine/core'; +import { + HouseLineIcon, + UserListIcon, + TreeIcon, + GraduationCapIcon, + TrendUpIcon, + PencilSimpleIcon, + Icon, +} from '@phosphor-icons/react'; import { createChartItem, createTableItem } from '@/utils/itemFactory'; import { ChartStack } from '@/components/Charts'; import { useProfile } from '@/components/profile/profileStore'; @@ -10,9 +28,15 @@ import { buildFilters, } from '@/components/FilterUI/useApplyFilters'; -import classes from './Tabs.module.css'; +import { motion } from 'motion/react'; +import classes from './Tabs.module.css'; import { useEffect, useState } from 'react'; + +// within data viewer imports import { ChartDef, chartDefs } from '@/components/Charts/configs/ChartDefs'; +import { FONT_MONO, COLOR } from './theme'; +import { FieldLabel } from './FieldLabel'; +import { MetricsPanel } from './MetricsPanels'; // Imports needed for stat cards import { BASE_API_URL } from '@/config'; @@ -48,62 +72,20 @@ function StatCards() { return acc; }, {}); - const formatNumber = (v?: number) => - v === undefined || v === null ? '—' : v.toLocaleString(); - - return ( - - - - {formatNumber(metrics['Population (ACS)'])} - - - Population - - - - - - ${formatNumber(metrics['Median Household Income'])} - - - Household Income - - - - - - {formatNumber(metrics['Median Age'])} - - - Median Age - - - - - - {formatNumber(metrics['Labor Force Participation Rate (16+)'])} - - - In Labor Force (16+) - - - - - - ${formatNumber(metrics['Median Home Value'])} - - - Median Home Value - - - - ); + return ; } -interface Section {id: string; label: string; icon: Icon} +interface Section { + id: string; + label: string; + icon: Icon; +} -function ChartTabs({sections, activeTab, setActiveTab}: { +function ChartTabs({ + sections, + activeTab, + setActiveTab, +}: { sections: Section[]; activeTab: string | null; setActiveTab: (v: string | null) => void; @@ -112,11 +94,10 @@ function ChartTabs({sections, activeTab, setActiveTab}: { @@ -134,9 +115,232 @@ function ChartTabs({sections, activeTab, setActiveTab}: { ); } +function HeroSection({ + myLocation, + comparison, + interests, + yearMin, + yearMax, + openProfileModal, + sections, + activeTab, + setActiveTab, +}: { + myLocation: any; + comparison: any; + interests: string[]; + yearMin: number; + yearMax: number; + openProfileModal: () => void; + sections: Section[]; + activeTab: string | null; + setActiveTab: (v: string | null) => void; +}) { + return ( + // Full-bleed: breaks out of the page's centered Container so the hero + // touches both edges of the viewport instead of floating as a card. + + + + + + + Data Viewer + + + + {myLocation?.name || 'No Location Selected'} + {comparison?.name && ( + <Text + span + style={{ + fontFamily: FONT_DISPLAY, + fontWeight: 400, + fontSize: '0.4em', + color: 'rgba(246,245,239,0.45)', + display: 'block', + marginTop: 8, + }} + > + compared to {comparison.name} + </Text> + )} + + + {/* Profile panel — sits beside the title, always on the dark + background so the light text stays legible. Kept compact so + it doesn't compete with the location title. */} + + + + + + Your profile + + + + + + + + + Interests + {interests.length > 0 ? ( + + {interests.join(' · ')} + + ) : ( + + None selected + + )} + + + + + + + + {/* Stat cards — quick-glance snapshot for the selected location */} + + + + + {/* Category tabs live in the hero so they read as part of the + location's summary rather than a separate page section. */} + + + + + + + + ); +} + +function ProfileField({ + label, + value, + small, +}: { + label: string; + value?: string; + small?: boolean; +}) { + return ( + + {label} + + {value || '—'} + + + ); +} export default function DataViewerPage() { - const { myLocation, comparison, interests, yearMin, yearMax } = useProfile(); + const { + myLocation, + comparison, + interests, + yearMin, + yearMax, + openProfileModal, + } = useProfile(); const [chartData, setChartData] = useState< Record >({}); @@ -147,8 +351,8 @@ export default function DataViewerPage() { Record >({}); - const [focusMode, setFocusMode] = useState<'all' | 'focus'>('all'); + const [activeTab, setActiveTab] = useState(null); const applyFilters = useApplyFilters(); const tableDefs = chartDefs.filter((c) => @@ -166,26 +370,30 @@ export default function DataViewerPage() { 'Labor & Economy': TrendUpIcon, }; - const sections = [...new Set(chartDefs.flatMap((c) => c.categories ?? []))] - .map((category) => ({ + const sections = [ + ...new Set(chartDefs.flatMap((c) => c.categories ?? [])), + ].map((category) => ({ id: category.toLowerCase().replace(/\s+/g, '-'), label: category, icon: categoryIcons[category] ?? HouseLineIcon, })); - useEffect(() => { nonTableDefs.forEach((chart: ChartDef) => { const url = chart.url; const filters = buildFilters(myLocation, { col: 'year', - selected: [chart.chartParams?.fixedYear ?? yearMin, - chart.chartParams?.fixedYear ?? yearMax], + selected: [ + chart.chartParams?.fixedYear ?? yearMin, + chart.chartParams?.fixedYear ?? yearMax, + ], }); const compFilters = buildFilters(comparison, { col: 'year', - selected: [chart.chartParams?.fixedYear ?? yearMin, - chart.chartParams?.fixedYear ?? yearMax], + selected: [ + chart.chartParams?.fixedYear ?? yearMin, + chart.chartParams?.fixedYear ?? yearMax, + ], }); applyFilters({ @@ -279,7 +487,7 @@ export default function DataViewerPage() { notes: `County-level data (${employmentCounty} County) — QCEW does not report employment at the town level.`, }); } - + return createChartItem({ title: myLocation.name, xField: chart.xField, @@ -321,7 +529,6 @@ export default function DataViewerPage() { ); const allItems = [...charts, ...tableItems]; - let filteredItems = allItems; if (focusMode === 'focus' && interests.length > 0) { @@ -330,81 +537,43 @@ export default function DataViewerPage() { ); } - const [activeTab, setActiveTab] = useState(null); - - if (activeTab) { - const section = sections.find( - (s) => s.id === activeTab - ); + const section = sections.find((s) => s.id === activeTab); - if (section) { - filteredItems = filteredItems.filter((item) => - item.categories?.includes(section.label) - ); + if (section) { + filteredItems = filteredItems.filter((item) => + item.categories?.includes(section.label), + ); + } } -} - -const visibleItems = filteredItems; - -return ( - - - {/* Hero */} - - {/* Hero content */} - - - Data Viewer - - - - {myLocation.name} - - - Compared with {comparison.name} - + const visibleItems = filteredItems; - - - - - - - - - - {/* Charts... */} - + return ( + + + + {/* Charts... */} + c.chartParams?.defId - )} + defIds={visibleItems.map((c) => c.chartParams?.defId)} view="gallery" /> - + - -); -} \ No newline at end of file + ); +} diff --git a/frontend/src/app/data-viewer/theme.ts b/frontend/src/app/data-viewer/theme.ts new file mode 100644 index 0000000..202a050 --- /dev/null +++ b/frontend/src/app/data-viewer/theme.ts @@ -0,0 +1,15 @@ +export const COLOR = { + spruce: '#1B3A2F', + spruceDeep: '#122820', + slate: '#40525A', + birch: '#F6F5EF', + birchDim: '#EEEBE0', + ink: '#1B211D', + amber: '#dd9a2f', + amberSoft: '#E7B563', + amberYellow: '#FFD100', + line: 'rgba(27, 58, 47, 0.14)', +}; +export const FONT_DISPLAY = "'Fraunces', 'Iowan Old Style', serif"; +export const FONT_BODY = "'General Sans', 'Inter', sans-serif"; +export const FONT_MONO = "'IBM Plex Mono', ui-monospace, monospace"; diff --git a/frontend/src/app/working-report/page.tsx b/frontend/src/app/working-report/page.tsx index 44adda1..3ec3c05 100644 --- a/frontend/src/app/working-report/page.tsx +++ b/frontend/src/app/working-report/page.tsx @@ -22,11 +22,303 @@ import { useApplyFilters, buildFilters, } from '@/components/FilterUI/useApplyFilters'; +import { motion } from 'motion/react'; +import { + PencilSimpleIcon, + DownloadSimpleIcon, + Icon, + XIcon, +} from '@phosphor-icons/react'; import { ChartDef, chartDefs } from '@/components/Charts/configs/ChartDefs'; import { createChartItem, createTableItem } from '@/utils/itemFactory'; import { useItems } from '@/components/ItemsProvider'; import { PdfModeContext } from '@/contexts/PdfModeContext'; +const COLOR = { + spruce: '#1B3A2F', + spruceDeep: '#122820', + slate: '#40525A', + birch: '#F6F5EF', + birchDim: '#EEEBE0', + ink: '#1B211D', + amber: '#dd9a2f', + amberSoft: '#E7B563', + amberYellow: '#FFD100', + line: 'rgba(27, 58, 47, 0.14)', +}; +const FONT_DISPLAY = "'Fraunces', 'Iowan Old Style', serif"; +const FONT_BODY = "'General Sans', 'Inter', sans-serif"; +const FONT_MONO = "'IBM Plex Mono', ui-monospace, monospace"; + +function HeroSection({ + myLocation, + comparison, + interests, + yearMin, + yearMax, + openProfileModal, + isGenerating, + handleDownloadPdf, + handleClearReport, +}: { + myLocation: any; + comparison: any; + interests: string[]; + yearMin: number; + yearMax: number; + openProfileModal: () => void; + isGenerating: boolean; + handleDownloadPdf: () => void; + handleClearReport: () => void; +}) { + return ( + // Full-bleed: breaks out of the page's centered Container so the hero + // touches both edges of the viewport instead of floating as a card. + + + + + + + Working Report + + + + {myLocation?.name || 'No Location Selected'} + {comparison?.name && ( + <Text + span + style={{ + fontFamily: FONT_DISPLAY, + fontWeight: 400, + fontSize: '0.4em', + color: 'rgba(246, 245, 239, 0.58)', + display: 'block', + marginTop: 8, + }} + > + compared to {comparison.name} + </Text> + )} + + + + {/* Profile panel — sits beside the title, always on the dark + background so the light text stays legible. Kept compact so + it doesn't compete with the location title. */} + + + + + + Report Summary + + + + + + + + + Interests + {interests.length > 0 ? ( + + {interests.join(' · ')} + + ) : ( + + None selected + + )} + + + + + + + + + ); +} + +function FieldLabel({ + children, + small, +}: { + children: React.ReactNode; + small?: boolean; +}) { + return ( + + {children} + + ); +} + +function ProfileField({ + label, + value, + small, +}: { + label: string; + value?: string; + small?: boolean; +}) { + return ( + + {label} + + {value || '—'} + + + ); +} + +type ReportActionsProps = { + isGenerating: boolean; + onDownload: () => void; + onClear: () => void; +}; + +function ReportActions({ + isGenerating, + onDownload, + onClear, +}: ReportActionsProps) { + return ( + + + + + + ); +} + export default function WorkingReport() { const chartsRef = useRef(null); const [isPdfMode, setIsPdfMode] = useState(false); @@ -113,13 +405,17 @@ export default function WorkingReport() { const url = chart.url; const filters = buildFilters(myLocation, { col: 'year', - selected: [chart.chartParams?.fixedYear ?? yearMin, - chart.chartParams?.fixedYear ?? yearMax], + selected: [ + chart.chartParams?.fixedYear ?? yearMin, + chart.chartParams?.fixedYear ?? yearMax, + ], }); const compFilters = buildFilters(comparison, { col: 'year', - selected: [chart.chartParams?.fixedYear ?? yearMin, - chart.chartParams?.fixedYear ?? yearMax], + selected: [ + chart.chartParams?.fixedYear ?? yearMin, + chart.chartParams?.fixedYear ?? yearMax, + ], }); applyFilters({ @@ -299,124 +595,27 @@ export default function WorkingReport() { } }; - function reportSummary() { - return ( - - - - Report Summary - - - - LOCATION - - - {myLocation.name} - - - - - COMPARED WITH - - - - {comparison.name} - - - - - - REPORT PERIOD - - - - {yearMin}–{yearMax} - - - - - ); - } - - function reportActionButtons() { - return ( - - - - - ); - } - - function reportHeaderCard() { - return ( - - - - - - Working Report - - - - Tailor your personalized report to your specific interests and - needs. - - {reportActionButtons()} - - {`${includedPairs.length} of ${allPairs.length} charts included in report`} - - - - {reportSummary()} - - - ); - } + const handleClearReport = () => { + clearItems(); + clearExclusions(); + setPendingReset(true); + openProfileModal(); + }; return ( - {reportHeaderCard()} +