Skip to content

Commit 90a95fb

Browse files
Improve fan chart
1 parent 19c447e commit 90a95fb

3 files changed

Lines changed: 777 additions & 2 deletions

File tree

app/Http/Livewire/FanChart.php

Lines changed: 91 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,103 @@ class FanChart extends Widget
99
{
1010
protected string $view = 'filament.widgets.fan-chart-widget';
1111

12+
public $rootPersonId = null;
13+
public $generations = 5;
14+
public $showNames = true;
15+
public $showDates = false;
16+
public $colorScheme = 'generation';
17+
18+
public function mount($rootPersonId = null, $generations = 5)
19+
{
20+
$this->rootPersonId = $rootPersonId ?? Person::first()?->id;
21+
$this->generations = $generations;
22+
}
23+
1224
public function getData(): array
1325
{
26+
if (!$this->rootPersonId) {
27+
return ['fanData' => [], 'rootPerson' => null];
28+
}
29+
30+
$rootPerson = Person::with(['childInFamily.husband', 'childInFamily.wife'])->find($this->rootPersonId);
31+
$fanData = $this->buildFanData($rootPerson, $this->generations);
32+
1433
return [
15-
'people' => Person::all(), // Fetch all people/person data. Adjust query as needed for performance or specific requirements.
34+
'fanData' => $fanData,
35+
'rootPerson' => $rootPerson,
36+
'generations' => $this->generations,
37+
'showNames' => $this->showNames,
38+
'showDates' => $this->showDates,
39+
'colorScheme' => $this->colorScheme,
1640
];
1741
}
1842

19-
#[\Override]
43+
private function buildFanData($person, $maxGenerations, $generation = 0): array
44+
{
45+
if (!$person || $generation >= $maxGenerations) {
46+
return [];
47+
}
48+
49+
$personData = [
50+
'id' => $person->id,
51+
'name' => $person->fullname(),
52+
'givn' => $person->givn,
53+
'surn' => $person->surn,
54+
'sex' => $person->sex,
55+
'birth_date' => $person->birthday?->format('Y-m-d'),
56+
'death_date' => $person->deathday?->format('Y-m-d'),
57+
'birth_year' => $person->birthday?->format('Y'),
58+
'death_year' => $person->deathday?->format('Y'),
59+
'generation' => $generation,
60+
'children' => []
61+
];
62+
63+
// For fan chart, we build ancestors (parents) not descendants
64+
if ($person->childInFamily && $generation < $maxGenerations - 1) {
65+
$family = $person->childInFamily;
66+
67+
if ($family->husband) {
68+
$personData['children'][] = $this->buildFanData($family->husband, $maxGenerations, $generation + 1);
69+
}
70+
71+
if ($family->wife) {
72+
$personData['children'][] = $this->buildFanData($family->wife, $maxGenerations, $generation + 1);
73+
}
74+
}
75+
76+
return $personData;
77+
}
78+
79+
public function setRootPerson($personId): void
80+
{
81+
$this->rootPersonId = $personId;
82+
$this->dispatch('refreshFanChart');
83+
}
84+
85+
public function setGenerations($generations): void
86+
{
87+
$this->generations = max(2, min(8, $generations));
88+
$this->dispatch('refreshFanChart');
89+
}
90+
91+
public function toggleNames(): void
92+
{
93+
$this->showNames = !$this->showNames;
94+
$this->dispatch('refreshFanChart');
95+
}
96+
97+
public function toggleDates(): void
98+
{
99+
$this->showDates = !$this->showDates;
100+
$this->dispatch('refreshFanChart');
101+
}
102+
103+
public function setColorScheme($scheme): void
104+
{
105+
$this->colorScheme = $scheme;
106+
$this->dispatch('refreshFanChart');
107+
}
108+
20109
public function render(): \Illuminate\Contracts\View\View
21110
{
22111
return view(static::$view, $this->getData());

public/js/fan-chart.js

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
/**
2+
* Fan Chart JavaScript Library
3+
* Enhanced genealogy fan chart with D3.js
4+
*/
5+
6+
class FanChart {
7+
constructor(containerId, options = {}) {
8+
this.containerId = containerId;
9+
this.options = {
10+
width: 800,
11+
height: 600,
12+
innerRadius: 50,
13+
showNames: true,
14+
showDates: false,
15+
colorScheme: 'generation',
16+
generations: 5,
17+
...options
18+
};
19+
20+
this.svg = null;
21+
this.g = null;
22+
this.zoom = null;
23+
this.data = null;
24+
}
25+
26+
render(data) {
27+
this.data = data;
28+
this.clear();
29+
this.createSvg();
30+
this.renderChart();
31+
}
32+
33+
clear() {
34+
d3.select(`#${this.containerId}`).selectAll("*").remove();
35+
}
36+
37+
createSvg() {
38+
const container = d3.select(`#${this.containerId}`);
39+
const containerNode = container.node();
40+
const rect = containerNode.getBoundingClientRect();
41+
42+
this.options.width = rect.width || this.options.width;
43+
this.options.height = rect.height || this.options.height;
44+
45+
const radius = Math.min(this.options.width, this.options.height) / 2 - 20;
46+
47+
this.svg = container
48+
.append("svg")
49+
.attr("width", this.options.width)
50+
.attr("height", this.options.height);
51+
52+
this.g = this.svg.append("g")
53+
.attr("transform", `translate(${this.options.width/2},${this.options.height/2})`);
54+
55+
// Add zoom behavior
56+
this.zoom = d3.zoom()
57+
.scaleExtent([0.5, 3])
58+
.on("zoom", (event) => {
59+
this.g.attr("transform",
60+
`translate(${this.options.width/2},${this.options.height/2}) ${event.transform}`
61+
);
62+
});
63+
64+
this.svg.call(this.zoom);
65+
}
66+
67+
renderChart() {
68+
if (!this.data) return;
69+
70+
const radius = Math.min(this.options.width, this.options.height) / 2 - 20;
71+
72+
// Convert data to hierarchical structure
73+
const root = d3.hierarchy(this.data);
74+
75+
// Create partition layout
76+
const partition = d3.partition()
77+
.size([2 * Math.PI, radius]);
78+
79+
partition(root);
80+
81+
// Create arc generator
82+
const arc = d3.arc()
83+
.startAngle(d => d.x0)
84+
.endAngle(d => d.x1)
85+
.innerRadius(d => Math.max(this.options.innerRadius, d.y0))
86+
.outerRadius(d => d.y1);
87+
88+
// Draw segments
89+
this.g.selectAll(".fan-segment")
90+
.data(root.descendants())
91+
.enter()
92+
.append("path")
93+
.attr("class", d => `fan-segment ${this.getSegmentClass(d)}`)
94+
.attr("d", arc)
95+
.style("fill", d => this.getSegmentColor(d))
96+
.style("stroke", "#fff")
97+
.style("stroke-width", 1)
98+
.style("cursor", "pointer")
99+
.on("click", (event, d) => this.onSegmentClick(event, d))
100+
.on("mouseover", (event, d) => this.showTooltip(event, d))
101+
.on("mouseout", () => this.hideTooltip());
102+
103+
// Add text labels
104+
this.addTextLabels(root.descendants().filter(d => d.depth > 0));
105+
}
106+
107+
addTextLabels(nodes) {
108+
if (!this.options.showNames && !this.options.showDates) return;
109+
110+
const textGroups = this.g.selectAll(".fan-text-group")
111+
.data(nodes)
112+
.enter()
113+
.append("g")
114+
.attr("class", "fan-text-group");
115+
116+
textGroups.each((d, i, nodes) => {
117+
const textGroup = d3.select(nodes[i]);
118+
const angle = (d.x0 + d.x1) / 2;
119+
const radius = (d.y0 + d.y1) / 2;
120+
const x = Math.sin(angle) * radius;
121+
const y = -Math.cos(angle) * radius;
122+
123+
textGroup.attr("transform", `translate(${x},${y}) rotate(${angle * 180 / Math.PI - 90})`);
124+
125+
if (this.options.showNames && d.data.name) {
126+
this.addNameText(textGroup, d);
127+
}
128+
129+
if (this.options.showDates) {
130+
this.addDateText(textGroup, d);
131+
}
132+
});
133+
}
134+
135+
addNameText(textGroup, d) {
136+
const nameText = textGroup.append("text")
137+
.attr("class", "fan-text name")
138+
.attr("text-anchor", "middle")
139+
.attr("dy", this.options.showDates ? "-0.2em" : "0.3em")
140+
.style("font-size", "11px")
141+
.style("font-weight", "600")
142+
.style("fill", "#1f2937");
143+
144+
const name = d.data.name;
145+
if (name.length > 15) {
146+
const parts = name.split(' ');
147+
if (parts.length > 1) {
148+
nameText.append("tspan")
149+
.attr("x", 0)
150+
.text(parts[0]);
151+
nameText.append("tspan")
152+
.attr("x", 0)
153+
.attr("dy", "1em")
154+
.text(parts.slice(1).join(' '));
155+
} else {
156+
nameText.text(name.substring(0, 12) + '...');
157+
}
158+
} else {
159+
nameText.text(name);
160+
}
161+
}
162+
163+
addDateText(textGroup, d) {
164+
const birthYear = d.data.birth_year || '?';
165+
const deathYear = d.data.death_year || '';
166+
const dateText = `${birthYear}${deathYear ? '-' + deathYear : ''}`;
167+
168+
textGroup.append("text")
169+
.attr("class", "fan-text dates")
170+
.attr("text-anchor", "middle")
171+
.attr("dy", this.options.showNames ? "1em" : "0.3em")
172+
.style("font-size", "9px")
173+
.style("fill", "#6b7280")
174+
.text(dateText);
175+
}
176+
177+
getSegmentClass(d) {
178+
return `generation-${d.depth}`;
179+
}
180+
181+
getSegmentColor(d) {
182+
switch (this.options.colorScheme) {
183+
case 'generation':
184+
const colors = ['#10b981', '#3b82f6', '#8b5cf6', '#f59e0b', '#ef4444', '#ec4899', '#06b6d4', '#84cc16'];
185+
return colors[d.depth % colors.length];
186+
187+
case 'gender':
188+
const sex = d.data.sex?.toLowerCase();
189+
return sex === 'm' ? '#3b82f6' : sex === 'f' ? '#ec4899' : '#6b7280';
190+
191+
case 'branch':
192+
if (d.depth === 0) return '#10b981';
193+
let current = d;
194+
while (current.parent && current.parent.depth > 0) {
195+
current = current.parent;
196+
}
197+
const isPaternal = current.parent && current.parent.children.indexOf(current) === 0;
198+
return isPaternal ? '#3b82f6' : '#ec4899';
199+
200+
default:
201+
return '#3b82f6';
202+
}
203+
}
204+
205+
onSegmentClick(event, d) {
206+
if (this.options.onPersonClick && d.data.id) {
207+
this.options.onPersonClick(d.data.id);
208+
}
209+
}
210+
211+
showTooltip(event, d) {
212+
const tooltip = d3.select("body").append("div")
213+
.attr("class", "fan-tooltip")
214+
.style("position", "absolute")
215+
.style("background", "rgba(0,0,0,0.8)")
216+
.style("color", "white")
217+
.style("padding", "8px")
218+
.style("border-radius", "4px")
219+
.style("font-size", "12px")
220+
.style("pointer-events", "none")
221+
.style("z-index", "1000");
222+
223+
let content = `<strong>${d.data.name || 'Unknown'}</strong>`;
224+
if (d.data.birth_year || d.data.death_year) {
225+
content += `<br>${d.data.birth_year || '?'} - ${d.data.death_year || ''}`;
226+
}
227+
content += `<br>Generation: ${d.depth}`;
228+
content += `<br>Click to expand`;
229+
230+
tooltip.html(content)
231+
.style("left", (event.pageX + 10) + "px")
232+
.style("top", (event.pageY - 10) + "px");
233+
}
234+
235+
hideTooltip() {
236+
d3.selectAll(".fan-tooltip").remove();
237+
}
238+
239+
zoomIn() {
240+
this.svg.transition().call(this.zoom.scaleBy, 1.5);
241+
}
242+
243+
zoomOut() {
244+
this.svg.transition().call(this.zoom.scaleBy, 1 / 1.5);
245+
}
246+
247+
resetZoom() {
248+
this.svg.transition().call(this.zoom.transform, d3.zoomIdentity);
249+
}
250+
251+
updateOptions(newOptions) {
252+
this.options = { ...this.options, ...newOptions };
253+
if (this.data) {
254+
this.render(this.data);
255+
}
256+
}
257+
}
258+
259+
// Global functions for backward compatibility
260+
function initializeFanChart(data, options = {}) {
261+
const chart = new FanChart('fanChartContainer', options);
262+
chart.render(data);
263+
return chart;
264+
}
265+
266+
// Export for module systems
267+
if (typeof module !== 'undefined' && module.exports) {
268+
module.exports = FanChart;
269+
}

0 commit comments

Comments
 (0)