Rehaul of graph component
This commit is contained in:
+189
-77
@@ -6,12 +6,14 @@ import {
|
||||
SKILL_RADIUS_DEFAULT, SKILL_RADIUS_ACTIVE,
|
||||
MOBILE_ROLE_WIDTH, MOBILE_LABEL_MAX_LEN,
|
||||
MOBILE_SKILL_RADIUS_DEFAULT, MOBILE_SKILL_RADIUS_ACTIVE,
|
||||
DOMAIN_COLOR_MAP, prefersReducedMotion,
|
||||
DOMAIN_COLOR_MAP, HIDDEN_ENTITY_IDS, prefersReducedMotion,
|
||||
LINK_BASE_WIDTH, LINK_STRENGTH_WIDTH_FACTOR,
|
||||
LINK_BASE_OPACITY, LINK_STRENGTH_OPACITY_FACTOR,
|
||||
LINK_BEZIER_VERTICAL_OFFSET,
|
||||
SKILL_STROKE_WIDTH, SKILL_STROKE_OPACITY, SKILL_SIZE_ROLE_FACTOR,
|
||||
SKILL_GLOW_STD_DEVIATION,
|
||||
SKILL_Y_OFFSET_STEP, SKILL_Y_OFFSET_STEP_MOBILE,
|
||||
SKILL_Y_GLOBAL_OFFSET_RATIO, SKILL_X_OVERLAP_MAX_RATIO,
|
||||
} from '@/components/constellation/constants'
|
||||
import type { SimNode, SimLink, LayoutParams } from '@/components/constellation/types'
|
||||
|
||||
@@ -28,13 +30,24 @@ function isEntityNode(type: string): boolean {
|
||||
return type === 'role' || type === 'education'
|
||||
}
|
||||
|
||||
function fractionalYear(node: { startDate?: string; startYear?: number }): number {
|
||||
if (node.startDate) {
|
||||
const d = new Date(node.startDate)
|
||||
const year = d.getFullYear()
|
||||
const start = new Date(year, 0, 1).getTime()
|
||||
const end = new Date(year + 1, 0, 1).getTime()
|
||||
return year + (d.getTime() - start) / (end - start)
|
||||
}
|
||||
return node.startYear ?? 2016
|
||||
}
|
||||
|
||||
function getHeight(width: number, containerHeight?: number | null): number {
|
||||
if (width < 1024) return 520
|
||||
if (width < 768) return 520
|
||||
if (containerHeight && containerHeight > 0) return Math.max(400, containerHeight)
|
||||
return 400
|
||||
}
|
||||
|
||||
const roleNodes = constellationNodes.filter(n => n.type === 'role' || n.type === 'education')
|
||||
const roleNodes = constellationNodes.filter(n => (n.type === 'role' || n.type === 'education') && !HIDDEN_ENTITY_IDS.has(n.id))
|
||||
|
||||
export function useForceSimulation(
|
||||
svgRef: React.RefObject<SVGSVGElement | null>,
|
||||
@@ -70,9 +83,11 @@ export function useForceSimulation(
|
||||
|
||||
svg.selectAll('*').remove()
|
||||
|
||||
const years = roleNodes.map(n => n.startYear ?? 2016)
|
||||
const years = roleNodes.map(n => fractionalYear(n))
|
||||
const now = new Date()
|
||||
const currentFractionalYear = now.getFullYear() + now.getMonth() / 12
|
||||
const minYear = Math.min(...years)
|
||||
const maxYear = Math.max(...years)
|
||||
const maxYear = Math.max(...years, currentFractionalYear)
|
||||
|
||||
const rw = isMobile ? MOBILE_ROLE_WIDTH : Math.round(ROLE_WIDTH * sf)
|
||||
const rh = isMobile ? ROLE_HEIGHT : Math.round(ROLE_HEIGHT * sf)
|
||||
@@ -94,9 +109,12 @@ export function useForceSimulation(
|
||||
}
|
||||
layoutParamsRef.current = layoutParams
|
||||
|
||||
const yScale = d3.scaleLinear()
|
||||
.domain([maxYear, minYear])
|
||||
// Power scale gives more space to recent (dense) years, compresses older ones
|
||||
const yearSpan = maxYear - minYear
|
||||
const rawScale = d3.scalePow().exponent(0.5)
|
||||
.domain([0, yearSpan])
|
||||
.range([topPadding, height - bottomPadding])
|
||||
const yScale = (year: number) => rawScale(maxYear - year)
|
||||
|
||||
// Background rect
|
||||
svg.append('rect')
|
||||
@@ -149,31 +167,62 @@ export function useForceSimulation(
|
||||
.attr('id', `role-grad-${i}`)
|
||||
.attr('x1', '0%').attr('y1', '0%')
|
||||
.attr('x2', '100%').attr('y2', '0%')
|
||||
grad.append('stop').attr('offset', '0%').attr('stop-color', color).attr('stop-opacity', 0.08)
|
||||
grad.append('stop').attr('offset', '100%').attr('stop-color', color).attr('stop-opacity', 0.18)
|
||||
grad.append('stop').attr('offset', '0%').attr('stop-color', color).attr('stop-opacity', 0.15)
|
||||
grad.append('stop').attr('offset', '100%').attr('stop-color', color).attr('stop-opacity', 0.3)
|
||||
})
|
||||
const orgColorGradientMap = new Map(uniqueOrgColors.map((c, i) => [c, `url(#role-grad-${i})`]))
|
||||
|
||||
// Year indicator (for animation)
|
||||
const yearIndicator = svg.append('text')
|
||||
.attr('class', 'year-indicator')
|
||||
.attr('x', sidePadding + 8)
|
||||
.attr('y', topPadding - 4)
|
||||
.attr('font-size', isMobile ? '18' : `${Math.round(24 * sf)}`)
|
||||
.attr('font-family', 'var(--font-ui)')
|
||||
.attr('fill', 'var(--text-tertiary)')
|
||||
// Date indicator group (for animation) — month + year with clip mask for scroll effect
|
||||
const dateFontSize = isMobile ? 18 : Math.round(24 * sf)
|
||||
const dateX = width * 0.1
|
||||
const dateY = topPadding - 4
|
||||
const lineHeight = Math.round(dateFontSize * 1.3)
|
||||
const clipId = 'date-indicator-clip'
|
||||
|
||||
const dateClip = defs.append('clipPath').attr('id', clipId)
|
||||
dateClip.append('rect')
|
||||
.attr('x', dateX - 4)
|
||||
.attr('y', dateY - dateFontSize - 2)
|
||||
.attr('width', isMobile ? 120 : Math.round(160 * sf))
|
||||
.attr('height', lineHeight + 4)
|
||||
|
||||
const dateGroup = svg.append('g')
|
||||
.attr('class', 'date-indicator')
|
||||
.attr('clip-path', `url(#${clipId})`)
|
||||
.attr('opacity', 0)
|
||||
yearIndicatorRef.current = yearIndicator as unknown as d3.Selection<SVGTextElement, unknown, null, undefined>
|
||||
|
||||
dateGroup.append('text')
|
||||
.attr('class', 'date-month')
|
||||
.attr('x', dateX)
|
||||
.attr('y', dateY)
|
||||
.attr('font-size', dateFontSize)
|
||||
.attr('font-family', 'var(--font-geist-mono)')
|
||||
.attr('font-weight', 500)
|
||||
.attr('fill', 'var(--text-tertiary)')
|
||||
.attr('letter-spacing', '0.08em')
|
||||
|
||||
dateGroup.append('text')
|
||||
.attr('class', 'date-year')
|
||||
.attr('x', dateX + (isMobile ? 52 : Math.round(68 * sf)))
|
||||
.attr('y', dateY)
|
||||
.attr('font-size', dateFontSize)
|
||||
.attr('font-family', 'var(--font-geist-mono)')
|
||||
.attr('font-weight', 300)
|
||||
.attr('fill', 'var(--text-tertiary)')
|
||||
.attr('opacity', 0.6)
|
||||
|
||||
yearIndicatorRef.current = dateGroup as unknown as d3.Selection<SVGTextElement, unknown, null, undefined>
|
||||
|
||||
// Timeline guides
|
||||
const timelineGroup = svg.append('g').attr('class', 'timeline-guides')
|
||||
timelineGroupRef.current = timelineGroup as unknown as d3.Selection<SVGGElement, unknown, null, undefined>
|
||||
|
||||
const tickYears = d3.range(minYear, maxYear + 1)
|
||||
const tickYears = d3.range(Math.ceil(minYear), Math.floor(maxYear) + 1)
|
||||
timelineGroup.selectAll('line.year-guide')
|
||||
.data(tickYears)
|
||||
.join('line')
|
||||
.attr('class', 'year-guide')
|
||||
.attr('data-year', d => d)
|
||||
.attr('x1', sidePadding)
|
||||
.attr('x2', width - sidePadding)
|
||||
.attr('y1', d => yScale(d))
|
||||
@@ -183,10 +232,16 @@ export function useForceSimulation(
|
||||
.attr('stroke-width', 1)
|
||||
.attr('stroke-dasharray', '3 4')
|
||||
|
||||
const labelSpace = isMobile ? 26 : Math.round(28 * sf)
|
||||
const axisRightPadding = isMobile ? 16 : Math.round(12 * sf)
|
||||
const axisX = width - axisRightPadding - labelSpace
|
||||
|
||||
const topTickY = tickYears.length > 0 ? yScale(tickYears[0]) : topPadding
|
||||
timelineGroup.append('line')
|
||||
.attr('x1', timelineX)
|
||||
.attr('x2', timelineX)
|
||||
.attr('y1', topPadding - 12)
|
||||
.attr('class', 'axis-line')
|
||||
.attr('x1', axisX)
|
||||
.attr('x2', axisX)
|
||||
.attr('y1', topTickY - 12)
|
||||
.attr('y2', height - bottomPadding + 12)
|
||||
.attr('stroke', 'var(--border)')
|
||||
.attr('stroke-width', 1)
|
||||
@@ -195,86 +250,124 @@ export function useForceSimulation(
|
||||
.data(tickYears)
|
||||
.join('line')
|
||||
.attr('class', 'year-tick')
|
||||
.attr('x1', timelineX)
|
||||
.attr('x2', d => timelineX + (roleNodes.some(r => r.startYear === d) ? 8 : 6))
|
||||
.attr('data-year', d => d)
|
||||
.attr('x1', axisX)
|
||||
.attr('x2', d => axisX - (roleNodes.some(r => r.startYear === d) ? 8 : 6))
|
||||
.attr('y1', d => yScale(d))
|
||||
.attr('y2', d => yScale(d))
|
||||
.attr('stroke', 'var(--border)')
|
||||
.attr('stroke-width', 1)
|
||||
.attr('stroke-opacity', d => roleNodes.some(r => r.startYear === d) ? 0.8 : 0.4)
|
||||
.attr('stroke-opacity', 1)
|
||||
|
||||
timelineGroup.selectAll('text.year-label')
|
||||
.data(tickYears)
|
||||
.join('text')
|
||||
.attr('class', 'year-label')
|
||||
.attr('x', width - sidePadding)
|
||||
.attr('data-year', d => d)
|
||||
.attr('x', axisX + 8)
|
||||
.attr('y', d => yScale(d) + Math.round(4 * sf))
|
||||
.attr('text-anchor', 'end')
|
||||
.attr('text-anchor', 'start')
|
||||
.attr('font-size', isMobile ? '9' : `${Math.round(11 * sf)}`)
|
||||
.attr('font-family', 'var(--font-ui)')
|
||||
.attr('fill', 'var(--text-tertiary)')
|
||||
.attr('opacity', 1)
|
||||
.text(d => d)
|
||||
|
||||
// Prepare data
|
||||
const links: SimLink[] = constellationLinks.map(l => ({
|
||||
// Prepare data — filter out hidden entities and their exclusive links/skills
|
||||
const visibleLinks = constellationLinks.filter(l => !HIDDEN_ENTITY_IDS.has(l.source))
|
||||
const visibleSkillIds = new Set(visibleLinks.map(l => l.target))
|
||||
const visibleNodeData = constellationNodes.filter(n =>
|
||||
HIDDEN_ENTITY_IDS.has(n.id) ? false : (isEntityNode(n.type) || visibleSkillIds.has(n.id))
|
||||
)
|
||||
const links: SimLink[] = visibleLinks.map(l => ({
|
||||
source: l.source,
|
||||
target: l.target,
|
||||
strength: l.strength,
|
||||
}))
|
||||
|
||||
const roleOrder = [...roleNodes].sort((a, b) => (a.startYear ?? 0) - (b.startYear ?? 0))
|
||||
const roleOrder = [...roleNodes].sort((a, b) => fractionalYear(a) - fractionalYear(b))
|
||||
const roleInitialMap = new Map<string, { x: number; y: number }>()
|
||||
const roleGap = isMobile ? 40 : Math.round(56 * sf)
|
||||
const roleX = Math.min(width - sidePadding - rw / 2, timelineX + roleGap + rw / 2)
|
||||
const roleGap = isMobile ? 54 : Math.round(54 * sf)
|
||||
const roleX = axisX - roleGap - rw / 2
|
||||
|
||||
roleOrder.forEach((role) => {
|
||||
roleInitialMap.set(role.id, {
|
||||
x: roleX,
|
||||
y: yScale(role.startYear ?? minYear),
|
||||
y: yScale(fractionalYear(role)),
|
||||
})
|
||||
})
|
||||
|
||||
const nodes: SimNode[] = constellationNodes.map(n => {
|
||||
// Skills occupy the left ~65% of the chart
|
||||
const skillZoneRight = roleX - rw / 2 - (isMobile ? 16 : Math.round(24 * sf))
|
||||
const skillZoneLeft = sidePadding + srActive
|
||||
const skillZoneWidth = skillZoneRight - skillZoneLeft
|
||||
|
||||
// Pre-compute skill homeY and group by role-set to offset overlaps
|
||||
const skillRoleKey = new Map<string, string>() // skillId -> sorted role key
|
||||
const skillBaseY = new Map<string, number>() // skillId -> base homeY
|
||||
const roleKeyGroups = new Map<string, string[]>() // roleKey -> [skillIds]
|
||||
|
||||
visibleNodeData.filter(n => n.type === 'skill').forEach(n => {
|
||||
const roleIds = visibleLinks.filter(l => l.target === n.id).map(l => l.source)
|
||||
const key = roleIds.slice().sort().join('|')
|
||||
skillRoleKey.set(n.id, key)
|
||||
|
||||
const positions = roleIds
|
||||
.map(roleId => roleInitialMap.get(roleId))
|
||||
.filter(Boolean) as Array<{ x: number; y: number }>
|
||||
const baseY = positions.length > 0
|
||||
? positions.reduce((sum, p) => sum + p.y, 0) / positions.length
|
||||
: height * 0.5
|
||||
skillBaseY.set(n.id, baseY)
|
||||
|
||||
if (!roleKeyGroups.has(key)) roleKeyGroups.set(key, [])
|
||||
roleKeyGroups.get(key)!.push(n.id)
|
||||
})
|
||||
|
||||
// For groups with >1 skill sharing the same roles, apply alternating y-offsets
|
||||
// and x-offsets that scale stronger for skills further left in the zone
|
||||
const skillYOffset = new Map<string, number>()
|
||||
const offsetStep = isMobile ? SKILL_Y_OFFSET_STEP_MOBILE : Math.round(SKILL_Y_OFFSET_STEP * sf)
|
||||
roleKeyGroups.forEach(ids => {
|
||||
if (ids.length <= 1) return
|
||||
ids.forEach((id, i) => {
|
||||
const centered = i - (ids.length - 1) / 2
|
||||
skillYOffset.set(id, centered * offsetStep)
|
||||
})
|
||||
})
|
||||
|
||||
const nodes: SimNode[] = visibleNodeData.map(n => {
|
||||
if (isEntityNode(n.type)) {
|
||||
const pos = roleInitialMap.get(n.id)!
|
||||
return { ...n, x: pos.x, y: pos.y, vx: 0, vy: 0, homeX: pos.x, homeY: pos.y }
|
||||
}
|
||||
|
||||
const roleIds = constellationLinks.filter(l => l.target === n.id).map(l => l.source)
|
||||
const linkedRolePositions = roleIds
|
||||
.map(roleId => roleInitialMap.get(roleId))
|
||||
.filter(Boolean) as Array<{ x: number; y: number }>
|
||||
|
||||
const skillGap = isMobile ? 20 : Math.round(28 * sf)
|
||||
const skillSpaceStart = roleX + rw / 2 + skillGap
|
||||
const skillSpaceMid = (skillSpaceStart + width - sidePadding) / 2
|
||||
const centroid = linkedRolePositions.length > 0
|
||||
? {
|
||||
x: Math.max(skillSpaceStart, linkedRolePositions.reduce((sum, p) => sum + p.x, 0) / linkedRolePositions.length + (isMobile ? 30 : Math.round(40 * sf))),
|
||||
y: linkedRolePositions.reduce((sum, p) => sum + p.y, 0) / linkedRolePositions.length,
|
||||
}
|
||||
: { x: skillSpaceMid, y: height * 0.5 }
|
||||
|
||||
const hash = hashString(n.id)
|
||||
const domainBaseAngle = n.domain === 'clinical'
|
||||
? Math.PI * 0.5
|
||||
: n.domain === 'leadership'
|
||||
? Math.PI * 1.35
|
||||
: Math.PI * 0.05
|
||||
const angle = domainBaseAngle + ((hash % 360) * Math.PI / 180) * 0.18
|
||||
const radius = (isMobile ? 25 : Math.round(35 * sf)) + (hash % (isMobile ? 25 : Math.round(35 * sf)))
|
||||
let homeX = skillZoneLeft + (hash % 1000) / 1000 * skillZoneWidth
|
||||
|
||||
const seededX = centroid.x + Math.cos(angle) * radius
|
||||
const seededY = centroid.y + Math.sin(angle) * radius
|
||||
// X-offset for overlapping groups: stronger push for skills further left
|
||||
const key = skillRoleKey.get(n.id) ?? ''
|
||||
const group = roleKeyGroups.get(key)
|
||||
if (group && group.length > 1) {
|
||||
const posInZone = (homeX - skillZoneLeft) / skillZoneWidth // 0 (left) to 1 (right)
|
||||
const pushStrength = 1 - (posInZone * 0) // stronger for left-positioned skills
|
||||
const idx = group.indexOf(n.id)
|
||||
const centered = idx - (group.length - 1) / 2
|
||||
const maxXOffset = skillZoneWidth * SKILL_X_OVERLAP_MAX_RATIO
|
||||
homeX += centered * pushStrength * maxXOffset / Math.max(1, (group.length - 1) / 2)
|
||||
homeX = Math.max(skillZoneLeft, Math.min(skillZoneRight, homeX))
|
||||
}
|
||||
|
||||
return { ...n, x: seededX, y: seededY, vx: 0, vy: 0, homeX: seededX, homeY: seededY }
|
||||
const homeY = (skillBaseY.get(n.id) ?? height * 0.5) + (skillYOffset.get(n.id) ?? 0) - height * SKILL_Y_GLOBAL_OFFSET_RATIO
|
||||
|
||||
return { ...n, x: homeX, y: homeY, vx: 0, vy: 0, homeX, homeY }
|
||||
})
|
||||
|
||||
nodesRef.current = nodes
|
||||
|
||||
// Build connected map
|
||||
const connectedMap = new Map<string, Set<string>>()
|
||||
constellationLinks.forEach(l => {
|
||||
visibleLinks.forEach(l => {
|
||||
if (!connectedMap.has(l.source)) connectedMap.set(l.source, new Set())
|
||||
if (!connectedMap.has(l.target)) connectedMap.set(l.target, new Set())
|
||||
connectedMap.get(l.source)!.add(l.target)
|
||||
@@ -291,7 +384,7 @@ export function useForceSimulation(
|
||||
skillRestRadiiRef.current = skillRestRadii
|
||||
|
||||
// Node-by-id lookup for link domain color resolution
|
||||
const nodeById = new Map(constellationNodes.map(n => [n.id, n]))
|
||||
const nodeById = new Map(visibleNodeData.map(n => [n.id, n]))
|
||||
|
||||
// Create SVG groups
|
||||
const linkGroup = svg.append('g').attr('class', 'links')
|
||||
@@ -339,6 +432,16 @@ export function useForceSimulation(
|
||||
.attr('stroke', 'transparent')
|
||||
.attr('stroke-width', 2)
|
||||
|
||||
nodeSelection.filter(entityFilter)
|
||||
.append('rect')
|
||||
.attr('class', 'node-bg')
|
||||
.attr('x', -rw / 2)
|
||||
.attr('y', -rh / 2)
|
||||
.attr('width', rw)
|
||||
.attr('height', rh)
|
||||
.attr('rx', rrx)
|
||||
.attr('fill', 'var(--surface)')
|
||||
|
||||
nodeSelection.filter(entityFilter)
|
||||
.append('rect')
|
||||
.attr('class', 'node-circle')
|
||||
@@ -348,8 +451,9 @@ export function useForceSimulation(
|
||||
.attr('height', rh)
|
||||
.attr('rx', rrx)
|
||||
.attr('fill', d => orgColorGradientMap.get(d.orgColor ?? 'var(--accent)') ?? d.orgColor ?? 'var(--accent)')
|
||||
.attr('data-base-fill', d => orgColorGradientMap.get(d.orgColor ?? 'var(--accent)') ?? d.orgColor ?? 'var(--accent)')
|
||||
.attr('stroke', d => d.orgColor ?? 'var(--accent)')
|
||||
.attr('stroke-opacity', 0.4)
|
||||
.attr('stroke-opacity', 0.8)
|
||||
.attr('stroke-width', 1)
|
||||
.attr('stroke-dasharray', d => d.type === 'education' ? '4 3' : null)
|
||||
|
||||
@@ -387,20 +491,29 @@ export function useForceSimulation(
|
||||
.attr('stroke-width', SKILL_STROKE_WIDTH)
|
||||
.attr('stroke-opacity', SKILL_STROKE_OPACITY)
|
||||
|
||||
const skillFontSize = isMobile ? 9 : Math.round(11 * sf)
|
||||
const skillLineHeight = Math.round(skillFontSize * 1.15)
|
||||
const skillLabelOffset = srActive + Math.round(14 * sf)
|
||||
|
||||
nodeSelection.filter(d => d.type === 'skill')
|
||||
.append('text')
|
||||
.attr('class', 'node-label')
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('dy', srActive + Math.round(14 * sf))
|
||||
.attr('fill', 'var(--text-secondary)')
|
||||
.attr('font-size', isMobile ? '9' : `${Math.round(11 * sf)}`)
|
||||
.attr('font-size', skillFontSize)
|
||||
.attr('font-family', 'var(--font-geist-mono)')
|
||||
.attr('pointer-events', 'none')
|
||||
.attr('opacity', 0.5)
|
||||
.text(d => {
|
||||
.each(function (d) {
|
||||
const label = d.shortLabel ?? d.label
|
||||
const maxLen = isMobile ? 12 : width < 500 ? 12 : 16
|
||||
return label.length > maxLen ? `${label.slice(0, maxLen - 1)}…` : label
|
||||
const words = label.split(/\s+/)
|
||||
const el = d3.select(this)
|
||||
words.forEach((word, i) => {
|
||||
el.append('tspan')
|
||||
.attr('x', 0)
|
||||
.attr('dy', i === 0 ? skillLabelOffset : skillLineHeight)
|
||||
.text(word)
|
||||
})
|
||||
})
|
||||
|
||||
// Entity connectors to timeline
|
||||
@@ -423,15 +536,15 @@ export function useForceSimulation(
|
||||
))
|
||||
.force('link', d3.forceLink<SimNode, SimLink>(links)
|
||||
.id(d => d.id)
|
||||
.distance(isMobile ? 56 : Math.round(72 * sf))
|
||||
.strength(d => (d as SimLink).strength * 0.5))
|
||||
.force('x', d3.forceX<SimNode>(d => d.homeX).strength(d => isEntityNode(d.type) ? 1.0 : 0.25))
|
||||
.distance(isMobile ? 56 : Math.round(120 * sf))
|
||||
.strength(d => (d as SimLink).strength * 0.15))
|
||||
.force('x', d3.forceX<SimNode>(d => d.homeX).strength(d => isEntityNode(d.type) ? 1.0 : 0.6))
|
||||
.force('y', d3.forceY<SimNode>(d => {
|
||||
if (isEntityNode(d.type)) {
|
||||
return yScale(d.startYear ?? minYear)
|
||||
return yScale(fractionalYear(d))
|
||||
}
|
||||
return d.homeY
|
||||
}).strength(d => isEntityNode(d.type) ? 0.98 : 0.18))
|
||||
}).strength(d => isEntityNode(d.type) ? 0.98 : 0.25))
|
||||
.force('collide', d3.forceCollide<SimNode>(d =>
|
||||
isEntityNode(d.type) ? Math.max(rw, rh) / 2 + (isMobile ? 8 : Math.round(10 * sf)) : srActive + (isMobile ? 14 : Math.round(16 * sf))
|
||||
).iterations(3))
|
||||
@@ -439,15 +552,14 @@ export function useForceSimulation(
|
||||
simulationRef.current = simulation
|
||||
|
||||
const skillBottomPadding = srActive + Math.round(14 * sf) + Math.round(12 * sf)
|
||||
const rightMargin = isMobile ? 16 : Math.round(32 * sf)
|
||||
|
||||
const renderTick = () => {
|
||||
nodes.forEach(d => {
|
||||
if (isEntityNode(d.type)) {
|
||||
d.x = Math.max(rw / 2 + 6, Math.min(width - rw / 2 - 6, d.x))
|
||||
d.x = Math.max(rw / 2 + 6, Math.min(axisX - roleGap - rw / 2 + rw / 2, d.x))
|
||||
d.y = Math.max(rh / 2 + topPadding, Math.min(height - rh / 2 - bottomPadding, d.y))
|
||||
} else {
|
||||
d.x = Math.max(srActive + 6, Math.min(width - srActive - rightMargin, d.x))
|
||||
d.x = Math.max(srActive + 6, Math.min(skillZoneRight, d.x))
|
||||
d.y = Math.max(srActive + topPadding, Math.min(height - skillBottomPadding, d.y))
|
||||
}
|
||||
})
|
||||
@@ -465,9 +577,9 @@ export function useForceSimulation(
|
||||
nodeSelection.attr('transform', d => `translate(${d.x},${d.y})`)
|
||||
|
||||
roleConnectors
|
||||
.attr('x1', timelineX)
|
||||
.attr('x1', d => d.x + rw / 2)
|
||||
.attr('y1', d => d.y)
|
||||
.attr('x2', d => d.x - rw / 2)
|
||||
.attr('x2', axisX)
|
||||
.attr('y2', d => d.y)
|
||||
|
||||
const nextNodePositions: Record<string, { x: number; y: number }> = {}
|
||||
|
||||
Reference in New Issue
Block a user