chore: auto-commit before merge (loop primary)
This commit is contained in:
@@ -250,7 +250,9 @@ export function DashboardLayout() {
|
||||
const [highlightedNodeId, setHighlightedNodeId] = useState<string | null>(null)
|
||||
const [highlightedRoleId, setHighlightedRoleId] = useState<string | null>(null)
|
||||
const [chronologyHeight, setChronologyHeight] = useState<number | null>(null)
|
||||
const [sidebarForceCollapsed, setSidebarForceCollapsed] = useState(false)
|
||||
const chronologyRef = useRef<HTMLDivElement>(null)
|
||||
const patientSummaryRef = useRef<HTMLDivElement>(null)
|
||||
const activeSection = useActiveSection()
|
||||
const { openPanel } = useDetailPanel()
|
||||
const careerConsultationsById = useMemo(
|
||||
@@ -258,6 +260,30 @@ export function DashboardLayout() {
|
||||
[],
|
||||
)
|
||||
|
||||
// Sidebar collapse when patient summary scrolls out of view (desktop only)
|
||||
useEffect(() => {
|
||||
const el = patientSummaryRef.current
|
||||
if (!el) return
|
||||
const mq = window.matchMedia('(min-width: 1024px)')
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (mq.matches) {
|
||||
setSidebarForceCollapsed(!entry.isIntersecting)
|
||||
}
|
||||
},
|
||||
{ threshold: 0 },
|
||||
)
|
||||
observer.observe(el)
|
||||
const handleResize = () => {
|
||||
if (!mq.matches) setSidebarForceCollapsed(false)
|
||||
}
|
||||
mq.addEventListener('change', handleResize)
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
mq.removeEventListener('change', handleResize)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Measure the chronology stream height so the constellation graph can match it
|
||||
useEffect(() => {
|
||||
const el = chronologyRef.current
|
||||
@@ -410,6 +436,7 @@ export function DashboardLayout() {
|
||||
activeSection={activeSection}
|
||||
onNavigate={scrollToSection}
|
||||
onSearchClick={handleSearchClick}
|
||||
forceCollapsed={sidebarForceCollapsed}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
@@ -427,7 +454,9 @@ export function DashboardLayout() {
|
||||
>
|
||||
<div className="dashboard-grid">
|
||||
{/* PatientSummaryTile — full width (includes Latest Results subsection) */}
|
||||
<PatientSummaryTile />
|
||||
<div ref={patientSummaryRef}>
|
||||
<PatientSummaryTile />
|
||||
</div>
|
||||
|
||||
{/* ProjectsTile — full width */}
|
||||
<ProjectsTile />
|
||||
|
||||
@@ -268,18 +268,20 @@ export function RepeatMedicationsSubsection({ onNodeHighlight }: RepeatMedicatio
|
||||
title="REPEAT MEDICATIONS"
|
||||
rightText="Active prescriptions"
|
||||
/>
|
||||
{groupedSkills.map((group, index) => (
|
||||
<CategorySection
|
||||
key={group.id}
|
||||
label={group.label}
|
||||
categoryId={group.id}
|
||||
skills={group.skills}
|
||||
onSkillClick={handleSkillClick}
|
||||
onViewAll={handleViewAll}
|
||||
isFirst={index === 0}
|
||||
onNodeHighlight={onNodeHighlight}
|
||||
/>
|
||||
))}
|
||||
<div className="medications-grid">
|
||||
{groupedSkills.map((group) => (
|
||||
<CategorySection
|
||||
key={group.id}
|
||||
label={group.label}
|
||||
categoryId={group.id}
|
||||
skills={group.skills}
|
||||
onSkillClick={handleSkillClick}
|
||||
onViewAll={handleViewAll}
|
||||
isFirst
|
||||
onNodeHighlight={onNodeHighlight}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ interface SidebarProps {
|
||||
activeSection: string
|
||||
onNavigate: (tileId: string) => void
|
||||
onSearchClick: () => void
|
||||
forceCollapsed?: boolean
|
||||
}
|
||||
|
||||
interface NavSection {
|
||||
@@ -162,7 +163,7 @@ function AlertFlag({ alert }: AlertFlagProps) {
|
||||
)
|
||||
}
|
||||
|
||||
export default function Sidebar({ activeSection, onNavigate, onSearchClick }: SidebarProps) {
|
||||
export default function Sidebar({ activeSection, onNavigate, onSearchClick, forceCollapsed }: SidebarProps) {
|
||||
const [isDesktop, setIsDesktop] = useState(() => window.matchMedia('(min-width: 1024px)').matches)
|
||||
const [isMobileExpanded, setIsMobileExpanded] = useState(false)
|
||||
|
||||
@@ -184,7 +185,7 @@ export default function Sidebar({ activeSection, onNavigate, onSearchClick }: Si
|
||||
return () => mediaQuery.removeEventListener('change', listener)
|
||||
}, [])
|
||||
|
||||
const isExpanded = isDesktop || isMobileExpanded
|
||||
const isExpanded = (isDesktop && !forceCollapsed) || isMobileExpanded
|
||||
|
||||
const handleNavActivate = (tileId: string) => {
|
||||
onNavigate(tileId)
|
||||
@@ -195,7 +196,7 @@ export default function Sidebar({ activeSection, onNavigate, onSearchClick }: Si
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isDesktop && isMobileExpanded && (
|
||||
{(!isDesktop || forceCollapsed) && isMobileExpanded && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close sidebar navigation"
|
||||
@@ -234,7 +235,7 @@ export default function Sidebar({ activeSection, onNavigate, onSearchClick }: Si
|
||||
}}
|
||||
className={isExpanded ? 'pmr-scrollbar' : undefined}
|
||||
>
|
||||
{!isDesktop && (
|
||||
{(!isDesktop || forceCollapsed) && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isExpanded ? 'Collapse sidebar navigation' : 'Expand sidebar navigation'}
|
||||
|
||||
@@ -42,6 +42,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
||||
const highlightedNodeIdRef = useRef<string | null>(highlightedNodeId ?? null)
|
||||
const [dimensions, setDimensions] = useState({ width: 800, height: MIN_HEIGHT, scaleFactor: 1 })
|
||||
const [focusedNodeId, setFocusedNodeId] = useState<string | null>(null)
|
||||
const [chartInView, setChartInView] = useState(true)
|
||||
|
||||
callbacksRef.current = { onRoleClick, onSkillClick, onNodeHover }
|
||||
|
||||
@@ -49,6 +50,18 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
||||
highlightedNodeIdRef.current = highlightedNodeId ?? null
|
||||
}, [highlightedNodeId])
|
||||
|
||||
// Track chart visibility for play/pause button
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => setChartInView(entry.isIntersecting),
|
||||
{ threshold: 0.1 },
|
||||
)
|
||||
observer.observe(container)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
@@ -235,6 +248,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
||||
isPlaying={animation.isPlaying}
|
||||
onToggle={animation.togglePlayPause}
|
||||
isMobile={isMobile}
|
||||
visible={chartInView}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -16,39 +16,60 @@ export const ConstellationLegend: React.FC<ConstellationLegendProps> = ({ isTouc
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
padding: '6px 12px',
|
||||
fontFamily: 'var(--font-geist-mono)',
|
||||
fontSize: '10px',
|
||||
color: 'var(--text-tertiary)',
|
||||
lineHeight: '24px',
|
||||
flexDirection: 'column',
|
||||
gap: '2px',
|
||||
padding: '8px 12px',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{items.map((item, i) => (
|
||||
<React.Fragment key={item.label}>
|
||||
{i > 0 && (
|
||||
<span style={{ color: 'var(--border)', userSelect: 'none' }} aria-hidden="true">·</span>
|
||||
)}
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '5px' }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: item.color,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
{item.label}{domainCounts?.[item.domain] != null ? ` (${domainCounts[item.domain]})` : ''}
|
||||
</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
<span style={{ color: 'var(--border)', userSelect: 'none' }} aria-hidden="true">·</span>
|
||||
<span style={{ opacity: 0.7 }}>{isTouch || supportsCoarsePointer ? 'Tap to explore connections' : 'Hover to explore connections'}</span>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontFamily: 'var(--font-ui)',
|
||||
color: 'var(--text-secondary)',
|
||||
opacity: 1,
|
||||
}}
|
||||
>
|
||||
{isTouch || supportsCoarsePointer ? 'Tap to explore connections' : 'Hover to explore connections'}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
fontFamily: 'var(--font-geist-mono)',
|
||||
fontSize: '12px',
|
||||
color: 'var(--text-tertiary)',
|
||||
lineHeight: '24px',
|
||||
}}
|
||||
>
|
||||
{items.map((item, i) => (
|
||||
<React.Fragment key={item.label}>
|
||||
{i > 0 && (
|
||||
<span style={{ color: 'var(--border)', userSelect: 'none' }} aria-hidden="true">·</span>
|
||||
)}
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '5px' }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: item.color,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
{item.label}{domainCounts?.[item.domain] != null ? ` (${domainCounts[item.domain]})` : ''}
|
||||
</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,9 +4,10 @@ interface PlayPauseButtonProps {
|
||||
isPlaying: boolean
|
||||
onToggle: () => void
|
||||
isMobile: boolean
|
||||
visible?: boolean
|
||||
}
|
||||
|
||||
export const PlayPauseButton: React.FC<PlayPauseButtonProps> = ({ isPlaying, onToggle, isMobile }) => {
|
||||
export const PlayPauseButton: React.FC<PlayPauseButtonProps> = ({ isPlaying, onToggle, isMobile, visible = true }) => {
|
||||
const size = isMobile ? 44 : 36
|
||||
const offset = isMobile ? 8 : 12
|
||||
|
||||
@@ -16,22 +17,25 @@ export const PlayPauseButton: React.FC<PlayPauseButtonProps> = ({ isPlaying, onT
|
||||
aria-label={isPlaying ? 'Pause animation' : 'Play animation'}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: offset,
|
||||
right: offset,
|
||||
left: offset,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: '50%',
|
||||
border: '1px solid var(--border-light)',
|
||||
border: '1.5px solid var(--border)',
|
||||
background: 'var(--surface)',
|
||||
boxShadow: '0 1px 4px rgba(26,43,42,0.10)',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
opacity: 0.6,
|
||||
opacity: visible ? 0.85 : 0,
|
||||
pointerEvents: visible ? 'auto' : 'none',
|
||||
transition: 'opacity 150ms ease',
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.opacity = '1')}
|
||||
onMouseLeave={e => (e.currentTarget.style.opacity = '0.6')}
|
||||
onMouseLeave={e => { if (visible) e.currentTarget.style.opacity = '0.85' }}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="var(--text-secondary)">
|
||||
|
||||
@@ -20,8 +20,8 @@ export const LABEL_REST_OPACITY = 0.5
|
||||
// Link visual params
|
||||
export const LINK_BASE_WIDTH = 0.5
|
||||
export const LINK_STRENGTH_WIDTH_FACTOR = 1.5
|
||||
export const LINK_BASE_OPACITY = 0.08
|
||||
export const LINK_STRENGTH_OPACITY_FACTOR = 0.12
|
||||
export const LINK_BASE_OPACITY = 0.04
|
||||
export const LINK_STRENGTH_OPACITY_FACTOR = 0.06
|
||||
export const LINK_HIGHLIGHT_BASE_WIDTH = 1
|
||||
export const LINK_HIGHLIGHT_STRENGTH_WIDTH_FACTOR = 2
|
||||
export const LINK_BEZIER_VERTICAL_OFFSET = 0.15
|
||||
|
||||
@@ -51,6 +51,7 @@ export function useConstellationHighlight(deps: {
|
||||
nodeSelection.filter(d => d.type !== 'skill')
|
||||
.attr('filter', null)
|
||||
.select('.node-circle')
|
||||
.attr('fill', null)
|
||||
.attr('fill-opacity', null)
|
||||
.attr('stroke-opacity', 0.4)
|
||||
.attr('stroke-width', 1)
|
||||
@@ -105,7 +106,8 @@ export function useConstellationHighlight(deps: {
|
||||
return null
|
||||
})
|
||||
.select('.node-circle')
|
||||
.attr('fill-opacity', d => d.id === activeNodeId ? 0.25 : null)
|
||||
.attr('fill', d => d.id === activeNodeId ? '#FFFFFF' : null)
|
||||
.attr('fill-opacity', d => d.id === activeNodeId ? 1 : null)
|
||||
.attr('stroke-opacity', d => {
|
||||
if (d.id === activeNodeId) return 1
|
||||
if (connected.has(d.id)) return 0.7
|
||||
|
||||
@@ -160,7 +160,7 @@ export function useForceSimulation(
|
||||
.attr('x', sidePadding + 8)
|
||||
.attr('y', topPadding - 4)
|
||||
.attr('font-size', isMobile ? '18' : `${Math.round(24 * sf)}`)
|
||||
.attr('font-family', 'var(--font-geist-mono)')
|
||||
.attr('font-family', 'var(--font-ui)')
|
||||
.attr('fill', 'var(--text-tertiary)')
|
||||
.attr('opacity', 0)
|
||||
yearIndicatorRef.current = yearIndicator as unknown as d3.Selection<SVGTextElement, unknown, null, undefined>
|
||||
@@ -207,11 +207,11 @@ export function useForceSimulation(
|
||||
.data(tickYears)
|
||||
.join('text')
|
||||
.attr('class', 'year-label')
|
||||
.attr('x', timelineX - (isMobile ? 8 : Math.round(12 * sf)))
|
||||
.attr('x', width - sidePadding)
|
||||
.attr('y', d => yScale(d) + Math.round(4 * sf))
|
||||
.attr('text-anchor', 'end')
|
||||
.attr('font-size', isMobile ? '9' : `${Math.round(11 * sf)}`)
|
||||
.attr('font-family', 'var(--font-geist-mono)')
|
||||
.attr('font-family', 'var(--font-ui)')
|
||||
.attr('fill', 'var(--text-tertiary)')
|
||||
.text(d => d)
|
||||
|
||||
|
||||
+16
-3
@@ -438,10 +438,10 @@ html {
|
||||
border-color: rgba(124, 58, 237, 0.28);
|
||||
}
|
||||
|
||||
/* Desktop: 2 columns */
|
||||
@media (min-width: 1024px) {
|
||||
/* Tablet+: 2 columns */
|
||||
@media (min-width: 768px) {
|
||||
.pathway-columns {
|
||||
grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr);
|
||||
grid-template-columns: minmax(0, 2fr) minmax(0, 3fr);
|
||||
align-items: start;
|
||||
gap: 22px;
|
||||
}
|
||||
@@ -453,6 +453,19 @@ html {
|
||||
}
|
||||
}
|
||||
|
||||
/* Repeat medications 3-column grid */
|
||||
.medications-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.medications-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== COMMAND PALETTE ANIMATIONS ===== */
|
||||
@keyframes palette-overlay-in {
|
||||
from { opacity: 0; }
|
||||
|
||||
Reference in New Issue
Block a user