mirror of
https://github.com/pepaskopec-blip/Graduately.git
synced 2026-10-27 15:14:35 +00:00
Stop Mac path-map labels from overlapping between rows.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
8bc4fcdc47
commit
59c2479f7d
2 changed files with 98 additions and 65 deletions
|
|
@ -13,14 +13,19 @@ struct MapNode: Identifiable {
|
||||||
private struct NodePos {
|
private struct NodePos {
|
||||||
var x: CGFloat
|
var x: CGFloat
|
||||||
var y: CGFloat
|
var y: CGFloat
|
||||||
|
var row: Int
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct Serpentine {
|
private struct Serpentine {
|
||||||
let points: [NodePos]
|
let points: [NodePos]
|
||||||
let width: CGFloat
|
let width: CGFloat
|
||||||
let height: CGFloat
|
let height: CGFloat
|
||||||
|
let spacing: CGFloat
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Keep enough room under/above each node for a two-line caption.
|
||||||
|
private let labelBand: CGFloat = 44
|
||||||
|
|
||||||
private func serpentine(
|
private func serpentine(
|
||||||
count n: Int,
|
count n: Int,
|
||||||
avail: CGFloat,
|
avail: CGFloat,
|
||||||
|
|
@ -29,30 +34,42 @@ private func serpentine(
|
||||||
spac: CGFloat,
|
spac: CGFloat,
|
||||||
gap: CGFloat,
|
gap: CGFloat,
|
||||||
wave: CGFloat,
|
wave: CGFloat,
|
||||||
|
nodeSize: CGFloat,
|
||||||
lift: CGFloat = 0
|
lift: CGFloat = 0
|
||||||
) -> Serpentine {
|
) -> Serpentine {
|
||||||
guard n > 0 else { return Serpentine(points: [], width: avail, height: my * 2) }
|
guard n > 0 else {
|
||||||
let scale = min(1, max(0.55, avail / (2 * mx + CGFloat(max(n - 1, 0)) * spac)))
|
return Serpentine(points: [], width: avail, height: my * 2, spacing: spac)
|
||||||
let smx = mx * scale
|
}
|
||||||
let smy = my * scale
|
|
||||||
let sspac = spac * scale
|
|
||||||
let sgap = gap * scale
|
|
||||||
let swave = wave * scale
|
|
||||||
let slift = lift * scale
|
|
||||||
|
|
||||||
|
// Pick the fewest rows that fit the preferred spacing in the available width.
|
||||||
|
// Do NOT pre-scale as if every node were on one row — that crushed tall maps.
|
||||||
var rows = 1
|
var rows = 1
|
||||||
|
var cols = n
|
||||||
while rows <= n {
|
while rows <= n {
|
||||||
let cols = (n + rows - 1) / rows
|
cols = max(1, (n + rows - 1) / rows)
|
||||||
if 2 * smx + CGFloat(cols - 1) * sspac <= avail + 1 { break }
|
if 2 * mx + CGFloat(cols - 1) * spac <= avail + 1 { break }
|
||||||
rows += 1
|
rows += 1
|
||||||
}
|
}
|
||||||
rows = min(rows, n)
|
rows = min(rows, n)
|
||||||
let cols = max(1, (n + rows - 1) / rows)
|
cols = max(1, (n + rows - 1) / rows)
|
||||||
|
|
||||||
|
let needed = 2 * mx + CGFloat(cols - 1) * spac
|
||||||
|
let scale = needed > avail ? max(0.72, avail / needed) : 1
|
||||||
|
|
||||||
|
let smx = mx * scale
|
||||||
|
let smy = max(my * scale, nodeSize / 2 + labelBand + 8)
|
||||||
|
let sspac = max(spac * scale, nodeSize + 36)
|
||||||
|
// Gap must clear labels that sit between rows (outward placement still
|
||||||
|
// needs space when wave lifts nodes toward each other).
|
||||||
|
let sgap = max(gap * scale, nodeSize + labelBand * 2 + 28)
|
||||||
|
let swave = min(wave * scale, sgap * 0.12)
|
||||||
|
let slift = lift * scale
|
||||||
|
|
||||||
let cw = 2 * smx + CGFloat(cols - 1) * sspac
|
let cw = 2 * smx + CGFloat(cols - 1) * sspac
|
||||||
let ch = 2 * smy + CGFloat(rows - 1) * sgap + slift
|
let ch = 2 * smy + CGFloat(rows - 1) * sgap + slift + labelBand
|
||||||
let phase = CGFloat(2 * Double.pi * 1.7 / Double(max(n - 1, 1)))
|
let phase = CGFloat(2 * Double.pi * 1.7 / Double(max(n - 1, 1)))
|
||||||
|
|
||||||
var pts = Array(repeating: NodePos(x: 0, y: 0), count: n)
|
var pts = Array(repeating: NodePos(x: 0, y: 0, row: 0), count: n)
|
||||||
for r in 0..<rows {
|
for r in 0..<rows {
|
||||||
let base = r * cols
|
let base = r * cols
|
||||||
let len = min(cols, n - base)
|
let len = min(cols, n - base)
|
||||||
|
|
@ -62,16 +79,17 @@ private func serpentine(
|
||||||
let cc = fwd ? c : cols - 1 - c
|
let cc = fwd ? c : cols - 1 - c
|
||||||
pts[i] = NodePos(
|
pts[i] = NodePos(
|
||||||
x: smx + CGFloat(cc) * sspac,
|
x: smx + CGFloat(cc) * sspac,
|
||||||
y: smy + slift + CGFloat(r) * sgap + swave * sin(CGFloat(i) * phase)
|
y: smy + slift + CGFloat(r) * sgap + swave * sin(CGFloat(i) * phase),
|
||||||
|
row: r
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Serpentine(points: pts, width: cw, height: ch)
|
return Serpentine(points: pts, width: cw, height: ch, spacing: sspac)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func catmull(_ pts: [NodePos], _ t: CGFloat) -> NodePos {
|
private func catmull(_ pts: [NodePos], _ t: CGFloat) -> NodePos {
|
||||||
let n = pts.count
|
let n = pts.count
|
||||||
guard n > 1 else { return pts.first ?? NodePos(x: 0, y: 0) }
|
guard n > 1 else { return pts.first ?? NodePos(x: 0, y: 0, row: 0) }
|
||||||
var k = Int(t)
|
var k = Int(t)
|
||||||
var u = t - CGFloat(k)
|
var u = t - CGFloat(k)
|
||||||
if k < 0 { k = 0; u = 0 }
|
if k < 0 { k = 0; u = 0 }
|
||||||
|
|
@ -80,10 +98,10 @@ private func catmull(_ pts: [NodePos], _ t: CGFloat) -> NodePos {
|
||||||
let p2 = pts[k + 1]
|
let p2 = pts[k + 1]
|
||||||
let p0 = k - 1 >= 0
|
let p0 = k - 1 >= 0
|
||||||
? pts[k - 1]
|
? pts[k - 1]
|
||||||
: NodePos(x: p1.x - (p2.x - p1.x), y: p1.y - (p2.y - p1.y))
|
: NodePos(x: p1.x - (p2.x - p1.x), y: p1.y - (p2.y - p1.y), row: p1.row)
|
||||||
let p3 = k + 2 < n
|
let p3 = k + 2 < n
|
||||||
? pts[k + 2]
|
? pts[k + 2]
|
||||||
: NodePos(x: p2.x + (p2.x - p1.x), y: p2.y + (p2.y - p1.y))
|
: NodePos(x: p2.x + (p2.x - p1.x), y: p2.y + (p2.y - p1.y), row: p2.row)
|
||||||
let u2 = u * u
|
let u2 = u * u
|
||||||
let u3 = u2 * u
|
let u3 = u2 * u
|
||||||
let x = 0.5 * (2 * p1.x + (-p0.x + p2.x) * u
|
let x = 0.5 * (2 * p1.x + (-p0.x + p2.x) * u
|
||||||
|
|
@ -92,75 +110,92 @@ private func catmull(_ pts: [NodePos], _ t: CGFloat) -> NodePos {
|
||||||
let y = 0.5 * (2 * p1.y + (-p0.y + p2.y) * u
|
let y = 0.5 * (2 * p1.y + (-p0.y + p2.y) * u
|
||||||
+ (2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * u2
|
+ (2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * u2
|
||||||
+ (-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * u3)
|
+ (-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * u3)
|
||||||
return NodePos(x: x, y: y)
|
return NodePos(x: x, y: y, row: p1.row)
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PathMap: View {
|
struct PathMap: View {
|
||||||
let nodes: [MapNode]
|
let nodes: [MapNode]
|
||||||
let palette: Palette
|
let palette: Palette
|
||||||
var litUntil: CGFloat = 0
|
var litUntil: CGFloat = 0
|
||||||
var nodeSize: CGFloat = 72
|
var nodeSize: CGFloat = 64
|
||||||
var mx: CGFloat = 90
|
var mx: CGFloat = 90
|
||||||
var my: CGFloat = 110
|
var my: CGFloat = 110
|
||||||
var spac: CGFloat = 170
|
var spac: CGFloat = 170
|
||||||
var gap: CGFloat = 170
|
var gap: CGFloat = 220
|
||||||
var wave: CGFloat = 28
|
var wave: CGFloat = 18
|
||||||
var branch: (index: Int, node: MapNode)? = nil
|
var branch: (index: Int, node: MapNode)? = nil
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
let layout = serpentine(
|
let layout = serpentine(
|
||||||
count: nodes.count,
|
count: nodes.count,
|
||||||
avail: max(geo.size.width, 320),
|
avail: max(geo.size.width - 24, 480),
|
||||||
mx: mx,
|
mx: mx,
|
||||||
my: my,
|
my: my,
|
||||||
spac: spac,
|
spac: spac,
|
||||||
gap: gap,
|
gap: gap,
|
||||||
wave: wave,
|
wave: wave,
|
||||||
lift: branch == nil ? 0 : 40
|
nodeSize: nodeSize,
|
||||||
|
lift: branch == nil ? 0 : 56
|
||||||
)
|
)
|
||||||
|
let labelWidth = min(118, max(72, layout.spacing - 16))
|
||||||
|
let canvasH = layout.height + labelBand + 24
|
||||||
ScrollView([.horizontal, .vertical]) {
|
ScrollView([.horizontal, .vertical]) {
|
||||||
ZStack(alignment: .topLeading) {
|
ZStack(alignment: .topLeading) {
|
||||||
railCanvas(points: layout.points)
|
railCanvas(points: layout.points)
|
||||||
.frame(width: layout.width, height: layout.height + 96)
|
.frame(width: layout.width, height: canvasH)
|
||||||
|
|
||||||
ForEach(Array(nodes.enumerated()), id: \.element.id) { i, node in
|
ForEach(Array(nodes.enumerated()), id: \.element.id) { i, node in
|
||||||
let pt = layout.points[i]
|
let pt = layout.points[i]
|
||||||
|
let labelBelow = pt.row % 2 == 0
|
||||||
nodeView(node, index: i)
|
nodeView(node, index: i)
|
||||||
.position(x: pt.x, y: pt.y)
|
.position(x: pt.x, y: pt.y)
|
||||||
Text(node.label)
|
Text(node.label)
|
||||||
.font(.caption.weight(.semibold))
|
.font(.caption2.weight(.semibold))
|
||||||
.foregroundStyle(node.locked ? palette.overlay : palette.text)
|
.foregroundStyle(node.locked ? palette.overlay : palette.text)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.frame(width: 140)
|
.lineLimit(2)
|
||||||
.position(x: pt.x, y: pt.y + nodeSize / 2 + 18)
|
.minimumScaleFactor(0.85)
|
||||||
|
.frame(width: labelWidth, height: labelBand, alignment: labelBelow ? .top : .bottom)
|
||||||
|
.position(
|
||||||
|
x: pt.x,
|
||||||
|
y: labelBelow
|
||||||
|
? pt.y + nodeSize / 2 + labelBand / 2 + 6
|
||||||
|
: pt.y - nodeSize / 2 - labelBand / 2 - 6
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let branch, branch.index < layout.points.count {
|
if let branch, branch.index < layout.points.count {
|
||||||
let base = layout.points[branch.index]
|
let base = layout.points[branch.index]
|
||||||
let tip = NodePos(x: base.x + 48, y: base.y - 96)
|
let tip = NodePos(x: base.x + 52, y: base.y - 110, row: base.row)
|
||||||
Path { p in
|
Path { p in
|
||||||
p.move(to: CGPoint(x: base.x, y: base.y - nodeSize / 2))
|
p.move(to: CGPoint(x: base.x, y: base.y - nodeSize / 2))
|
||||||
p.addQuadCurve(
|
p.addQuadCurve(
|
||||||
to: CGPoint(x: tip.x, y: tip.y + nodeSize / 2),
|
to: CGPoint(x: tip.x, y: tip.y + nodeSize / 2),
|
||||||
control: CGPoint(x: base.x + 10, y: tip.y + 20)
|
control: CGPoint(x: base.x + 12, y: tip.y + 24)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.stroke(palette.rail, style: StrokeStyle(lineWidth: 8, lineCap: .round))
|
.stroke(palette.rail, style: StrokeStyle(lineWidth: 8, lineCap: .round))
|
||||||
nodeView(branch.node, index: nil)
|
nodeView(branch.node, index: nil)
|
||||||
.position(x: tip.x, y: tip.y)
|
.position(x: tip.x, y: tip.y)
|
||||||
Text(branch.node.label)
|
Text(branch.node.label)
|
||||||
.font(.caption.weight(.semibold))
|
.font(.caption2.weight(.semibold))
|
||||||
.foregroundStyle(palette.text)
|
.foregroundStyle(palette.text)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.frame(width: 140)
|
.lineLimit(2)
|
||||||
.position(x: tip.x, y: tip.y + nodeSize / 2 + 18)
|
.frame(width: labelWidth, height: labelBand, alignment: .bottom)
|
||||||
|
.position(x: tip.x, y: tip.y - nodeSize / 2 - labelBand / 2 - 6)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(width: max(layout.width, geo.size.width), height: layout.height + 96, alignment: .topLeading)
|
.frame(
|
||||||
|
width: max(layout.width + 24, geo.size.width),
|
||||||
|
height: canvasH,
|
||||||
|
alignment: .topLeading
|
||||||
|
)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(minHeight: 360)
|
.frame(minHeight: 420)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
|
|
@ -182,13 +217,13 @@ struct PathMap: View {
|
||||||
ctx.stroke(path, with: .color(color), style: style)
|
ctx.stroke(path, with: .color(color), style: style)
|
||||||
}
|
}
|
||||||
let end = CGFloat(points.count - 1)
|
let end = CGFloat(points.count - 1)
|
||||||
stroke(0, end, color: palette.rail, width: 12)
|
stroke(0, end, color: palette.rail, width: 10)
|
||||||
if litUntil > 0 {
|
if litUntil > 0 {
|
||||||
stroke(0, min(litUntil, end), color: palette.accent, width: 12)
|
stroke(0, min(litUntil, end), color: palette.accent, width: 10)
|
||||||
}
|
}
|
||||||
let mutedStart = litUntil > 0 ? litUntil : 0
|
let mutedStart = litUntil > 0 ? litUntil : 0
|
||||||
if mutedStart < end {
|
if mutedStart < end {
|
||||||
stroke(mutedStart, end, color: palette.subtext.opacity(0.22), width: 12, dashed: true)
|
stroke(mutedStart, end, color: palette.subtext.opacity(0.22), width: 10, dashed: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -205,28 +240,26 @@ struct PathMap: View {
|
||||||
}
|
}
|
||||||
if node.finish {
|
if node.finish {
|
||||||
VStack(spacing: 2) {
|
VStack(spacing: 2) {
|
||||||
if let index { Text("\(index + 1)").font(.title3.weight(.black)) }
|
if let index { Text("\(index + 1)").font(.headline.weight(.black)) }
|
||||||
FinishFlagBadge(palette: palette)
|
FinishFlagBadge(palette: palette)
|
||||||
}
|
}
|
||||||
.foregroundStyle(palette.overlay)
|
.foregroundStyle(palette.overlay)
|
||||||
} else if node.locked {
|
} else if node.locked {
|
||||||
VStack(spacing: 2) {
|
VStack(spacing: 2) {
|
||||||
if let index { Text("\(index + 1)").font(.title3.weight(.black)) }
|
if let index { Text("\(index + 1)").font(.headline.weight(.black)) }
|
||||||
Image(systemName: "lock.fill").font(.caption.weight(.bold))
|
Image(systemName: "lock.fill").font(.caption2.weight(.bold))
|
||||||
}
|
}
|
||||||
.foregroundStyle(palette.overlay)
|
.foregroundStyle(palette.overlay)
|
||||||
} else if node.done {
|
} else if node.done {
|
||||||
VStack(spacing: 2) {
|
VStack(spacing: 2) {
|
||||||
if let index { Text("\(index + 1)").font(.title3.weight(.black)) }
|
if let index { Text("\(index + 1)").font(.headline.weight(.black)) }
|
||||||
Image(systemName: "checkmark").font(.caption.weight(.bold))
|
Image(systemName: "checkmark").font(.caption2.weight(.bold))
|
||||||
}
|
}
|
||||||
.foregroundStyle(palette.onAccent)
|
.foregroundStyle(palette.onAccent)
|
||||||
} else {
|
} else if let index {
|
||||||
if let index {
|
Text("\(index + 1)")
|
||||||
Text("\(index + 1)")
|
.font(.headline.weight(.black))
|
||||||
.font(.title3.weight(.black))
|
.foregroundStyle(node.current ? palette.onAccent : palette.text)
|
||||||
.foregroundStyle(node.current ? palette.onAccent : palette.text)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(width: nodeSize, height: nodeSize)
|
.frame(width: nodeSize, height: nodeSize)
|
||||||
|
|
|
||||||
|
|
@ -539,12 +539,12 @@ struct RoadmapScreen: View {
|
||||||
},
|
},
|
||||||
palette: vm.palette,
|
palette: vm.palette,
|
||||||
litUntil: lit,
|
litUntil: lit,
|
||||||
nodeSize: 78,
|
nodeSize: 68,
|
||||||
mx: 100,
|
mx: 88,
|
||||||
my: 120,
|
my: 120,
|
||||||
spac: 190,
|
spac: 168,
|
||||||
gap: 180,
|
gap: 240,
|
||||||
wave: 32
|
wave: 16
|
||||||
)
|
)
|
||||||
.padding(20)
|
.padding(20)
|
||||||
.backgroundExtensionEffect()
|
.backgroundExtensionEffect()
|
||||||
|
|
@ -626,12 +626,12 @@ struct UnitMapScreen: View {
|
||||||
},
|
},
|
||||||
palette: vm.palette,
|
palette: vm.palette,
|
||||||
litUntil: lit,
|
litUntil: lit,
|
||||||
nodeSize: 72,
|
nodeSize: 64,
|
||||||
mx: 96,
|
mx: 88,
|
||||||
my: 118,
|
my: 118,
|
||||||
spac: 180,
|
spac: 164,
|
||||||
gap: 170,
|
gap: 230,
|
||||||
wave: 28,
|
wave: 14,
|
||||||
branch: branch
|
branch: branch
|
||||||
)
|
)
|
||||||
.padding(20)
|
.padding(20)
|
||||||
|
|
@ -733,12 +733,12 @@ struct LessonListScreen: View {
|
||||||
},
|
},
|
||||||
palette: vm.palette,
|
palette: vm.palette,
|
||||||
litUntil: CGFloat(next ?? rows.count),
|
litUntil: CGFloat(next ?? rows.count),
|
||||||
nodeSize: 70,
|
nodeSize: 64,
|
||||||
mx: 92,
|
mx: 86,
|
||||||
my: 112,
|
my: 116,
|
||||||
spac: 176,
|
spac: 160,
|
||||||
gap: 168,
|
gap: 236,
|
||||||
wave: 26
|
wave: 14
|
||||||
)
|
)
|
||||||
.padding(20)
|
.padding(20)
|
||||||
.backgroundExtensionEffect()
|
.backgroundExtensionEffect()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue