Initial commit: webtrees full diagram chart module
Interactive SVG family tree visualization using ELK (Sugiyama) for layout and D3 for rendering. Shows ancestors, descendants, and siblings in a single diagram with orthogonal bus-line connectors. Features: - Bidirectional tree traversal (ancestors + descendants + siblings) - Generation-aligned layout with post-processing Y-snap - Person cards with photos, names, dates, and hover bio cards - "More ancestors" indicator for persons with hidden parents - Pan/zoom navigation - Docker dev environment
This commit is contained in:
13
.gitattributes
vendored
Normal file
13
.gitattributes
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
# Exclude dev files from distribution ZIP
|
||||
node_modules/ export-ignore
|
||||
docker/ export-ignore
|
||||
resources/js/modules/ export-ignore
|
||||
.github/ export-ignore
|
||||
.gitattributes export-ignore
|
||||
.gitignore export-ignore
|
||||
package.json export-ignore
|
||||
package-lock.json export-ignore
|
||||
rollup.config.js export-ignore
|
||||
composer.json export-ignore
|
||||
CLAUDE.md export-ignore
|
||||
todo/ export-ignore
|
||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
.build/
|
||||
vendor/
|
||||
dist/
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
docker/.env
|
||||
45
CLAUDE.md
Normal file
45
CLAUDE.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Full Diagram - webtrees Chart Module
|
||||
|
||||
## Project Overview
|
||||
webtrees 2.2 chart module that visualizes the entire family tree (ancestors, descendants, siblings) in a single interactive SVG diagram. Inspired by MyHeritage "Family View".
|
||||
|
||||
## Tech Stack
|
||||
- **PHP 8.2+** — webtrees 2.2 module (extends AbstractModule, implements ModuleChartInterface + ModuleCustomInterface)
|
||||
- **Namespace**: `FullDiagram\`
|
||||
- **D3.js v7** (cherry-picked: d3-hierarchy, d3-selection, d3-zoom, d3-shape, d3-transition) — SVG rendering
|
||||
- **Rollup** — JS bundling
|
||||
- **Docker Compose** — local dev (webtrees + MariaDB)
|
||||
|
||||
## Key Conventions
|
||||
- PSR-4 autoloading: `FullDiagram\` → `src/`
|
||||
- Views in `resources/views/modules/full-diagram/`
|
||||
- JS source in `resources/js/modules/`, built to `resources/js/full-diagram.min.js`
|
||||
- CSS in `resources/css/full-diagram.css`
|
||||
- Route pattern: `/tree/{tree}/full-diagram/{xref}`
|
||||
|
||||
## Development
|
||||
```bash
|
||||
# Start dev environment
|
||||
cd docker && docker compose up -d
|
||||
|
||||
# JS development
|
||||
npm install && npm run watch
|
||||
|
||||
# Build for release
|
||||
npm run prepare
|
||||
```
|
||||
|
||||
## Architecture
|
||||
- `module.php` — entry point, autoloads namespace, returns Module instance
|
||||
- `src/Module.php` — route registration, request handling, view rendering
|
||||
- `src/Configuration.php` — generation limits, display toggles
|
||||
- `src/Facade/DataFacade.php` — bidirectional tree traversal (ancestors + descendants + siblings)
|
||||
- `src/Model/NodeData.php` — person data (JsonSerializable)
|
||||
- `src/Model/FamilyNode.php` — family unit: couple + children
|
||||
- JS uses dual d3.tree() layout: ancestors upward, descendants downward, stitched at root
|
||||
|
||||
## Important Notes
|
||||
- webtrees uses `view()` helper for templates with `::` namespace syntax
|
||||
- Route registration uses `Registry::routeFactory()` in `boot()`
|
||||
- Access control via `Auth::checkComponentAccess()`
|
||||
- Individual data accessed via `$individual->facts()`, `childFamilies()`, `spouseFamilies()`
|
||||
661
LICENSE
Normal file
661
LICENSE
Normal file
@@ -0,0 +1,661 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
31
composer.json
Normal file
31
composer.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "alex/webtrees-full-diagram",
|
||||
"description": "Full family diagram chart module for webtrees — shows ancestors, descendants, and siblings in a single interactive SVG diagram.",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"type": "webtrees-module",
|
||||
"keywords": [
|
||||
"webtrees",
|
||||
"module",
|
||||
"chart",
|
||||
"family-tree",
|
||||
"diagram"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Alex",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"minimum-stability": "dev",
|
||||
"prefer-stable": true,
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"ext-json": "*",
|
||||
"fisharebest/webtrees": "~2.2.0 || dev-main"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"FullDiagram\\": "src/"
|
||||
}
|
||||
}
|
||||
}
|
||||
4
docker/.env.example
Normal file
4
docker/.env.example
Normal file
@@ -0,0 +1,4 @@
|
||||
MYSQL_ROOT_PASSWORD=root
|
||||
MYSQL_DATABASE=webtrees
|
||||
MYSQL_USER=webtrees
|
||||
MYSQL_PASSWORD=webtrees
|
||||
43
docker/docker-compose.yml
Normal file
43
docker/docker-compose.yml
Normal file
@@ -0,0 +1,43 @@
|
||||
services:
|
||||
webtrees:
|
||||
image: ghcr.io/nathanvaughn/webtrees:latest
|
||||
ports:
|
||||
- "8081:80"
|
||||
environment:
|
||||
PRETTY_URLS: "TRUE"
|
||||
LANG: "en-US"
|
||||
BASE_URL: "http://localhost:8081"
|
||||
DB_TYPE: "mysql"
|
||||
DB_HOST: "db"
|
||||
DB_PORT: "3306"
|
||||
DB_NAME: "webtrees"
|
||||
DB_USER: "webtrees"
|
||||
DB_PASS: "webtrees"
|
||||
volumes:
|
||||
- webtrees_data:/var/www/webtrees/data
|
||||
- ..:/var/www/webtrees/modules_v4/full-diagram
|
||||
- /home/alex/.local/src/webtrees_bocken_theme/BockenTheme:/var/www/webtrees/modules_v4/BockenTheme
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
db:
|
||||
image: mariadb:11
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: root
|
||||
MYSQL_DATABASE: webtrees
|
||||
MYSQL_USER: webtrees
|
||||
MYSQL_PASSWORD: webtrees
|
||||
volumes:
|
||||
- db_data:/var/lib/mysql
|
||||
healthcheck:
|
||||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
webtrees_data:
|
||||
db_data:
|
||||
21
module.php
Normal file
21
module.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Full Diagram module for webtrees.
|
||||
*
|
||||
* @license GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace FullDiagram;
|
||||
|
||||
use Composer\Autoload\ClassLoader;
|
||||
use Fisharebest\Webtrees\Registry;
|
||||
|
||||
// Register PSR-4 autoloader for our namespace
|
||||
$loader = new ClassLoader();
|
||||
$loader->addPsr4('FullDiagram\\', __DIR__ . '/src');
|
||||
$loader->register();
|
||||
|
||||
return Registry::container()->get(Module::class);
|
||||
990
package-lock.json
generated
Normal file
990
package-lock.json
generated
Normal file
@@ -0,0 +1,990 @@
|
||||
{
|
||||
"name": "webtrees-full-diagram",
|
||||
"version": "0.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "webtrees-full-diagram",
|
||||
"version": "0.2.0",
|
||||
"dependencies": {
|
||||
"@rollup/plugin-commonjs": "^29.0.2",
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-transition": "^3.0.1",
|
||||
"d3-zoom": "^3.0.0",
|
||||
"elkjs": "^0.11.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-node-resolve": "^16.0.3",
|
||||
"@rollup/plugin-terser": "^0.4.4",
|
||||
"rollup": "^4.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/source-map": {
|
||||
"version": "0.3.11",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz",
|
||||
"integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.25"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.1.0",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/plugin-commonjs": {
|
||||
"version": "29.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-29.0.2.tgz",
|
||||
"integrity": "sha512-S/ggWH1LU7jTyi9DxZOKyxpVd4hF/OZ0JrEbeLjXk/DFXwRny0tjD2c992zOUYQobLrVkRVMDdmHP16HKP7GRg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@rollup/pluginutils": "^5.0.1",
|
||||
"commondir": "^1.0.1",
|
||||
"estree-walker": "^2.0.2",
|
||||
"fdir": "^6.2.0",
|
||||
"is-reference": "1.2.1",
|
||||
"magic-string": "^0.30.3",
|
||||
"picomatch": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0 || 14 >= 14.17"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"rollup": "^2.68.0||^3.0.0||^4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"rollup": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/plugin-node-resolve": {
|
||||
"version": "16.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz",
|
||||
"integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@rollup/pluginutils": "^5.0.1",
|
||||
"@types/resolve": "1.20.2",
|
||||
"deepmerge": "^4.2.2",
|
||||
"is-module": "^1.0.0",
|
||||
"resolve": "^1.22.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"rollup": "^2.78.0||^3.0.0||^4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"rollup": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/plugin-terser": {
|
||||
"version": "0.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-0.4.4.tgz",
|
||||
"integrity": "sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"serialize-javascript": "^6.0.1",
|
||||
"smob": "^1.0.0",
|
||||
"terser": "^5.17.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"rollup": "^2.0.0||^3.0.0||^4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"rollup": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/pluginutils": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
|
||||
"integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0",
|
||||
"estree-walker": "^2.0.2",
|
||||
"picomatch": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"rollup": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
|
||||
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
|
||||
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
|
||||
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/resolve": {
|
||||
"version": "1.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
|
||||
"integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/commondir": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
|
||||
"integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-dispatch": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
||||
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-drag": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
|
||||
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-selection": "3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-selection": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-transition": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
|
||||
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3",
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-ease": "1 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-timer": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"d3-selection": "2 - 3"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-zoom": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
|
||||
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-drag": "2 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-selection": "2 - 3",
|
||||
"d3-transition": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/deepmerge": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/elkjs": {
|
||||
"version": "0.11.1",
|
||||
"resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.11.1.tgz",
|
||||
"integrity": "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==",
|
||||
"license": "EPL-2.0"
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
|
||||
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"picomatch": "^3 || ^4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"picomatch": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/is-core-module": {
|
||||
"version": "2.16.1",
|
||||
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
|
||||
"integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-module": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz",
|
||||
"integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-reference": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz",
|
||||
"integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/path-parse": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/randombytes": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
|
||||
"integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
"integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-core-module": "^2.16.1",
|
||||
"path-parse": "^1.0.7",
|
||||
"supports-preserve-symlinks-flag": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"resolve": "bin/resolve"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
"bin": {
|
||||
"rollup": "dist/bin/rollup"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0",
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-android-arm-eabi": "4.59.0",
|
||||
"@rollup/rollup-android-arm64": "4.59.0",
|
||||
"@rollup/rollup-darwin-arm64": "4.59.0",
|
||||
"@rollup/rollup-darwin-x64": "4.59.0",
|
||||
"@rollup/rollup-freebsd-arm64": "4.59.0",
|
||||
"@rollup/rollup-freebsd-x64": "4.59.0",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-x64-musl": "4.59.0",
|
||||
"@rollup/rollup-openbsd-x64": "4.59.0",
|
||||
"@rollup/rollup-openharmony-arm64": "4.59.0",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.59.0",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.59.0",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/serialize-javascript": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
|
||||
"integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"randombytes": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/smob": {
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/smob/-/smob-1.6.1.tgz",
|
||||
"integrity": "sha512-KAkBqZl3c2GvNgNhcoyJae1aKldDW0LO279wF9bk1PnluRTETKBq0WyzRXxEhoQLk56yHaOY4JCBEKDuJIET5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-support": {
|
||||
"version": "0.5.21",
|
||||
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
|
||||
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"source-map": "^0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-preserve-symlinks-flag": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
||||
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/terser": {
|
||||
"version": "5.46.0",
|
||||
"resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz",
|
||||
"integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"@jridgewell/source-map": "^0.3.3",
|
||||
"acorn": "^8.15.0",
|
||||
"commander": "^2.20.0",
|
||||
"source-map-support": "~0.5.20"
|
||||
},
|
||||
"bin": {
|
||||
"terser": "bin/terser"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/terser/node_modules/commander": {
|
||||
"version": "2.20.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
23
package.json
Normal file
23
package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "webtrees-full-diagram",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "rollup -c",
|
||||
"watch": "rollup -c --watch",
|
||||
"prepare": "npm run build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-node-resolve": "^16.0.3",
|
||||
"@rollup/plugin-terser": "^0.4.4",
|
||||
"rollup": "^4.9.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@rollup/plugin-commonjs": "^29.0.2",
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-transition": "^3.0.1",
|
||||
"d3-zoom": "^3.0.0",
|
||||
"elkjs": "^0.11.1"
|
||||
}
|
||||
}
|
||||
260
resources/css/full-diagram.css
Normal file
260
resources/css/full-diagram.css
Normal file
@@ -0,0 +1,260 @@
|
||||
/* Full Diagram Chart Styles */
|
||||
|
||||
/* ── Container ── */
|
||||
.full-diagram-container {
|
||||
position: relative;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.5rem;
|
||||
background: #f8f9fa;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.full-diagram-chart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.full-diagram-chart svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ── Person cards (SVG) ── */
|
||||
.person-card rect {
|
||||
fill: #e8e8e8;
|
||||
stroke: #b0b0b0;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.person-card.sex-m rect {
|
||||
fill: #d4e6f9;
|
||||
stroke: #7bafd4;
|
||||
}
|
||||
|
||||
.person-card.sex-f rect {
|
||||
fill: #f9d4e6;
|
||||
stroke: #d47ba8;
|
||||
}
|
||||
|
||||
.person-card.is-root rect {
|
||||
stroke: #495057;
|
||||
stroke-width: 2.5;
|
||||
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.2));
|
||||
}
|
||||
|
||||
.person-card:hover rect {
|
||||
filter: drop-shadow(0 3px 8px rgba(0, 0, 0, 0.15));
|
||||
}
|
||||
|
||||
/* Photo placeholder */
|
||||
.person-card .photo-placeholder {
|
||||
fill: #ddd;
|
||||
stroke: none;
|
||||
}
|
||||
|
||||
.person-card.sex-m .photo-placeholder {
|
||||
fill: #b8d4ed;
|
||||
}
|
||||
|
||||
.person-card.sex-f .photo-placeholder {
|
||||
fill: #edb8d4;
|
||||
}
|
||||
|
||||
.person-card .silhouette {
|
||||
fill: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
/* Card text */
|
||||
.person-card .person-name {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
fill: #212529;
|
||||
dominant-baseline: auto;
|
||||
}
|
||||
|
||||
.person-card .person-dates {
|
||||
font-size: 10px;
|
||||
fill: #6c757d;
|
||||
dominant-baseline: auto;
|
||||
}
|
||||
|
||||
.person-card .person-subtitle {
|
||||
font-size: 9px;
|
||||
fill: #868e96;
|
||||
font-style: italic;
|
||||
dominant-baseline: auto;
|
||||
}
|
||||
|
||||
/* ── More ancestors indicator ── */
|
||||
.more-ancestors-indicator rect {
|
||||
fill: #dee2e6;
|
||||
stroke: #adb5bd;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.more-ancestors-indicator line {
|
||||
stroke: #adb5bd;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.person-card.sex-m .more-ancestors-indicator rect {
|
||||
fill: #c4d9f0;
|
||||
stroke: #7bafd4;
|
||||
}
|
||||
|
||||
.person-card.sex-f .more-ancestors-indicator rect {
|
||||
fill: #f0c4d9;
|
||||
stroke: #d47ba8;
|
||||
}
|
||||
|
||||
/* ── Connector lines ── */
|
||||
.link {
|
||||
fill: none;
|
||||
stroke: #adb5bd;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.couple-link {
|
||||
stroke: #868e96;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.ancestor-link {
|
||||
stroke: #adb5bd;
|
||||
}
|
||||
|
||||
.descendant-link {
|
||||
stroke: #adb5bd;
|
||||
}
|
||||
|
||||
/* ── Bio card tooltip ── */
|
||||
.bio-card {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
background: #fff;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
padding: 12px;
|
||||
min-width: 220px;
|
||||
max-width: 300px;
|
||||
font-size: 12px;
|
||||
color: #212529;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.bio-header {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bio-photo {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bio-header-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bio-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.bio-age {
|
||||
font-size: 11px;
|
||||
color: #6c757d;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.bio-facts {
|
||||
border-top: 1px solid #eee;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.bio-fact {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 3px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.bio-fact-label {
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bio-fact-label::after {
|
||||
content: ":";
|
||||
}
|
||||
|
||||
.bio-fact-value {
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.bio-link {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid #eee;
|
||||
color: #4a90d9;
|
||||
text-decoration: none;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.bio-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ── Zoom controls ── */
|
||||
.zoom-controls {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
right: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.zoom-controls button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: #495057;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.zoom-controls button:hover {
|
||||
background: #e9ecef;
|
||||
}
|
||||
|
||||
/* ── Print styles ── */
|
||||
@media print {
|
||||
.full-diagram-container {
|
||||
border: none;
|
||||
height: auto !important;
|
||||
overflow: visible;
|
||||
}
|
||||
.zoom-controls {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
1
resources/js/full-diagram.min.js
vendored
Normal file
1
resources/js/full-diagram.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
14
resources/js/modules/custom/configuration.js
Normal file
14
resources/js/modules/custom/configuration.js
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Chart configuration defaults.
|
||||
*/
|
||||
export default class Configuration {
|
||||
constructor() {
|
||||
this.cardWidth = 200;
|
||||
this.cardHeight = 80;
|
||||
this.cardPadding = 10;
|
||||
this.horizontalSpacing = 30;
|
||||
this.verticalSpacing = 40;
|
||||
this.siblingSpacing = 30;
|
||||
this.duration = 500;
|
||||
}
|
||||
}
|
||||
44
resources/js/modules/custom/data.js
Normal file
44
resources/js/modules/custom/data.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Data loading and management.
|
||||
*/
|
||||
export default class DataLoader {
|
||||
constructor(baseUrl) {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load tree data for a specific individual via AJAX.
|
||||
*
|
||||
* @param {string} xref
|
||||
* @param {object} params
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
async load(xref, params = {}) {
|
||||
const url = new URL(this.baseUrl.replace("__XREF__", xref), window.location.origin);
|
||||
url.searchParams.set("ajax", "1");
|
||||
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load data: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a person's chart page.
|
||||
*
|
||||
* @param {string} xref
|
||||
*/
|
||||
navigateTo(xref) {
|
||||
window.location.href = this.baseUrl.replace("__XREF__", xref);
|
||||
}
|
||||
}
|
||||
270
resources/js/modules/custom/hierarchy.js
Normal file
270
resources/js/modules/custom/hierarchy.js
Normal file
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Dual-tree D3 hierarchy builder.
|
||||
*
|
||||
* Converts the PHP-generated tree data into two D3 hierarchies:
|
||||
* - Ancestor tree (root at bottom, growing upward)
|
||||
* - Descendant tree (root at top, growing downward)
|
||||
*
|
||||
* Both share the root node at (0, 0) and are merged for rendering.
|
||||
*
|
||||
* After D3 layout:
|
||||
* 1. Shift descendant subtrees so children center under the couple midpoint
|
||||
* 2. Compute spouse positions at every level
|
||||
* 3. Resolve all overlaps (person nodes, spouses, siblings) by pushing
|
||||
* cards apart and propagating shifts to subtrees
|
||||
*/
|
||||
import { tree, hierarchy } from "../lib/d3.js";
|
||||
import { computeSpouseOffset, SPOUSE_GAP } from "../lib/tree/spouse-util.js";
|
||||
|
||||
// ─── Ancestor hierarchy ──────────────────────────────────────────────
|
||||
|
||||
export function buildAncestorHierarchy(rootData) {
|
||||
if (!rootData.parents || rootData.parents.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const root = prepareAncestorNode(rootData);
|
||||
return hierarchy(root, (d) => d._ancestorChildren);
|
||||
}
|
||||
|
||||
function prepareAncestorNode(person) {
|
||||
const node = { ...person };
|
||||
if (person.parents && person.parents.length > 0) {
|
||||
node._ancestorChildren = person.parents.map((p) => prepareAncestorNode(p));
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
// ─── Descendant hierarchy ────────────────────────────────────────────
|
||||
|
||||
export function buildDescendantHierarchy(rootData) {
|
||||
if (!rootData.families || rootData.families.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const root = prepareDescendantNode(rootData);
|
||||
return hierarchy(root, (d) => d._descendantChildren);
|
||||
}
|
||||
|
||||
function prepareDescendantNode(person) {
|
||||
const node = { ...person };
|
||||
const children = [];
|
||||
|
||||
if (person.families) {
|
||||
for (let fi = 0; fi < person.families.length; fi++) {
|
||||
const family = person.families[fi];
|
||||
for (const child of family.children || []) {
|
||||
const childNode = prepareDescendantNode(child);
|
||||
childNode._familyIndex = fi;
|
||||
children.push(childNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (children.length > 0) {
|
||||
node._descendantChildren = children;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
// ─── Layout computation ──────────────────────────────────────────────
|
||||
|
||||
export function computeLayout(ancestorHierarchy, descendantHierarchy, config) {
|
||||
const nodeWidth = config.cardWidth + config.horizontalSpacing;
|
||||
const nodeHeight = config.cardHeight + config.verticalSpacing;
|
||||
const treeLayout = tree().nodeSize([nodeWidth, nodeHeight]);
|
||||
|
||||
const result = {
|
||||
ancestors: [],
|
||||
descendants: [],
|
||||
ancestorLinks: [],
|
||||
descendantLinks: [],
|
||||
};
|
||||
|
||||
// ── Ancestor tree (grows upward) ──
|
||||
if (ancestorHierarchy) {
|
||||
treeLayout(ancestorHierarchy);
|
||||
|
||||
ancestorHierarchy.each((node) => {
|
||||
node.y = -Math.abs(node.y);
|
||||
if (node.depth > 0) result.ancestors.push(node);
|
||||
});
|
||||
|
||||
ancestorHierarchy.links().forEach((link) => {
|
||||
result.ancestorLinks.push(link);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Descendant tree (grows downward) ──
|
||||
if (descendantHierarchy) {
|
||||
treeLayout(descendantHierarchy);
|
||||
|
||||
// Step 1: shift children to center under couple midpoints
|
||||
shiftChildrenToCoupleCenter(descendantHierarchy, config);
|
||||
|
||||
// Step 2: resolve overlaps between all cards on the same row
|
||||
// (person nodes + their spouse cards)
|
||||
resolveDescendantOverlaps(descendantHierarchy, config);
|
||||
|
||||
descendantHierarchy.each((node) => {
|
||||
if (node.depth > 0) result.descendants.push(node);
|
||||
});
|
||||
|
||||
descendantHierarchy.links().forEach((link) => {
|
||||
result.descendantLinks.push(link);
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Couple-center shifting ──────────────────────────────────────────
|
||||
|
||||
function shiftChildrenToCoupleCenter(root, config) {
|
||||
// Process bottom-up so nested shifts accumulate correctly
|
||||
root.each((node) => {
|
||||
const data = node.data;
|
||||
if (!data.families || data.families.length === 0 || !node.children) return;
|
||||
|
||||
if (data.families.length === 1) {
|
||||
const family = data.families[0];
|
||||
if (family.spouse) {
|
||||
const shift = computeSpouseOffset(0, config.cardWidth, SPOUSE_GAP) / 2;
|
||||
for (const child of node.children) {
|
||||
shiftSubtree(child, shift);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (const child of node.children) {
|
||||
const fi = child.data._familyIndex;
|
||||
if (fi === undefined) continue;
|
||||
const family = data.families[fi];
|
||||
if (family && family.spouse) {
|
||||
const shift = computeSpouseOffset(fi, config.cardWidth, SPOUSE_GAP) / 2;
|
||||
shiftSubtree(child, shift);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function shiftSubtree(node, dx) {
|
||||
node.x += dx;
|
||||
if (node.children) {
|
||||
for (const child of node.children) {
|
||||
shiftSubtree(child, dx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Overlap resolution ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Collect every card rectangle that will be rendered on each row
|
||||
* (person nodes + spouse cards), detect overlaps, and push apart.
|
||||
*
|
||||
* When a person node is pushed, its entire descendant subtree moves too.
|
||||
*
|
||||
* Strategy: for each depth row, build a sorted list of "card groups"
|
||||
* (a person + all their spouses form one rigid group). Then do a single
|
||||
* left-to-right sweep pushing groups apart when they overlap.
|
||||
*/
|
||||
function resolveDescendantOverlaps(root, config) {
|
||||
const w = config.cardWidth;
|
||||
const minGap = 20;
|
||||
|
||||
// Gather hierarchy nodes by depth
|
||||
const depthMap = new Map();
|
||||
root.each((node) => {
|
||||
if (!depthMap.has(node.depth)) depthMap.set(node.depth, []);
|
||||
depthMap.get(node.depth).push(node);
|
||||
});
|
||||
|
||||
const depths = [...depthMap.keys()].sort((a, b) => a - b);
|
||||
|
||||
for (const depth of depths) {
|
||||
const nodesAtDepth = depthMap.get(depth);
|
||||
|
||||
// Build card groups: each person node + their spouses as a rigid unit
|
||||
// A group has a leftEdge and rightEdge computed from all its cards.
|
||||
const groups = [];
|
||||
|
||||
for (const node of nodesAtDepth) {
|
||||
const xs = [node.x]; // person card center
|
||||
|
||||
const data = node.data;
|
||||
if (data.families) {
|
||||
for (let fi = 0; fi < data.families.length; fi++) {
|
||||
if (data.families[fi].spouse) {
|
||||
xs.push(node.x + computeSpouseOffset(fi, w, SPOUSE_GAP));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const leftEdge = Math.min(...xs) - w / 2;
|
||||
const rightEdge = Math.max(...xs) + w / 2;
|
||||
|
||||
groups.push({ node, leftEdge, rightEdge, centerX: node.x });
|
||||
}
|
||||
|
||||
// Sort by the left edge of each group
|
||||
groups.sort((a, b) => a.leftEdge - b.leftEdge);
|
||||
|
||||
// Single left-to-right sweep: push groups apart
|
||||
for (let i = 1; i < groups.length; i++) {
|
||||
const prev = groups[i - 1];
|
||||
const curr = groups[i];
|
||||
|
||||
const overlap = prev.rightEdge + minGap - curr.leftEdge;
|
||||
|
||||
if (overlap > 0) {
|
||||
// Push current group (and its subtree) right
|
||||
shiftSubtree(curr.node, overlap);
|
||||
curr.leftEdge += overlap;
|
||||
curr.rightEdge += overlap;
|
||||
curr.centerX += overlap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Sibling positions ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compute sibling positions at the same Y-level as root (0).
|
||||
*
|
||||
* Siblings are placed to the right of root. If the root has spouses,
|
||||
* siblings start after the rightmost spouse to avoid overlap.
|
||||
*/
|
||||
export function computeSiblingPositions(rootData, config) {
|
||||
const siblings = [];
|
||||
const links = [];
|
||||
|
||||
if (!rootData.siblings || rootData.siblings.length === 0) {
|
||||
return { siblings, links };
|
||||
}
|
||||
|
||||
// Find the rightmost occupied X at root level (root card + any spouses)
|
||||
let maxRootX = config.cardWidth / 2; // right edge of root card
|
||||
|
||||
if (rootData.families) {
|
||||
for (let fi = 0; fi < rootData.families.length; fi++) {
|
||||
if (rootData.families[fi].spouse) {
|
||||
const spouseX = computeSpouseOffset(fi, config.cardWidth, SPOUSE_GAP);
|
||||
const spouseRight = spouseX + config.cardWidth / 2;
|
||||
maxRootX = Math.max(maxRootX, spouseRight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const startX = maxRootX + config.siblingSpacing;
|
||||
|
||||
rootData.siblings.forEach((sibling, index) => {
|
||||
const x = startX + index * (config.cardWidth + config.siblingSpacing);
|
||||
const y = 0;
|
||||
|
||||
siblings.push({ x, y, data: sibling });
|
||||
links.push({ source: { x: 0, y: 0 }, target: { x, y } });
|
||||
});
|
||||
|
||||
return { siblings, links };
|
||||
}
|
||||
30
resources/js/modules/index.js
Normal file
30
resources/js/modules/index.js
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Full Diagram — entry point.
|
||||
*
|
||||
* Reads embedded data from the page and initializes the chart.
|
||||
*/
|
||||
import Chart from "./lib/chart.js";
|
||||
|
||||
async function init() {
|
||||
const data = window.fullDiagramData;
|
||||
const baseUrl = window.fullDiagramBaseUrl;
|
||||
|
||||
if (!data || !data.persons) {
|
||||
console.error("Full Diagram: No tree data found.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const chart = new Chart("#full-diagram-container", data, baseUrl);
|
||||
await chart.render();
|
||||
} catch (err) {
|
||||
console.error("Full Diagram: Render failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize when DOM is ready
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
90
resources/js/modules/lib/chart.js
Normal file
90
resources/js/modules/lib/chart.js
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Main chart orchestrator.
|
||||
*
|
||||
* Uses ELK for layout (Sugiyama / union-node pattern) and D3 for
|
||||
* SVG rendering with clean orthogonal bus-line connectors.
|
||||
*/
|
||||
import { computeElkLayout } from "./layout/elk-layout.js";
|
||||
import { createSvg, getCanvas } from "./chart/svg.js";
|
||||
import { initZoom, createZoomControls } from "./chart/zoom.js";
|
||||
import { renderPersonCard } from "./chart/box.js";
|
||||
import { hideTooltip } from "./chart/overlay.js";
|
||||
import { zoomIdentity } from "./d3.js";
|
||||
|
||||
export default class Chart {
|
||||
constructor(containerSelector, data, baseUrl) {
|
||||
this.containerSelector = containerSelector;
|
||||
this.data = data;
|
||||
this.config = {
|
||||
cardWidth: 200,
|
||||
cardHeight: 80,
|
||||
horizontalSpacing: 30,
|
||||
verticalSpacing: 60,
|
||||
};
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
async render() {
|
||||
const ctr = this.containerSelector;
|
||||
const chartSelector = `${ctr} .full-diagram-chart`;
|
||||
|
||||
const svg = createSvg(chartSelector);
|
||||
this.svg = svg;
|
||||
|
||||
const zoomBehavior = initZoom(svg);
|
||||
this.zoomBehavior = zoomBehavior;
|
||||
|
||||
svg.on("zoom.tooltip", () => hideTooltip());
|
||||
createZoomControls(ctr, svg, zoomBehavior);
|
||||
|
||||
const canvas = getCanvas(svg);
|
||||
|
||||
// Compute layout using ELK
|
||||
const layout = await computeElkLayout(
|
||||
this.data.persons,
|
||||
this.data.mainId,
|
||||
this.config
|
||||
);
|
||||
|
||||
// Click handler
|
||||
const baseUrl = this.baseUrl;
|
||||
const onNodeClick = (data) => {
|
||||
hideTooltip();
|
||||
const url = baseUrl.replace("__XREF__", data.id);
|
||||
window.location.href = url;
|
||||
};
|
||||
|
||||
// Draw connections first (behind cards)
|
||||
this.renderConnections(canvas, layout);
|
||||
|
||||
// Draw person cards
|
||||
for (const person of layout.persons) {
|
||||
renderPersonCard(canvas, person, this.config, onNodeClick, ctr);
|
||||
}
|
||||
|
||||
// Center on root
|
||||
this.centerOnRoot();
|
||||
}
|
||||
|
||||
renderConnections(canvas, layout) {
|
||||
const linkGroup = canvas.append("g").attr("class", "edges");
|
||||
|
||||
for (const conn of layout.connections) {
|
||||
linkGroup
|
||||
.append("path")
|
||||
.attr("class", conn.cssClass)
|
||||
.attr("d", conn.path);
|
||||
}
|
||||
}
|
||||
|
||||
centerOnRoot() {
|
||||
const { width, height } = this.svg.node().getBoundingClientRect();
|
||||
this.svg
|
||||
.transition()
|
||||
.duration(500)
|
||||
.call(
|
||||
this.zoomBehavior.transform,
|
||||
zoomIdentity.translate(width / 2, height / 2)
|
||||
);
|
||||
}
|
||||
}
|
||||
210
resources/js/modules/lib/chart/box.js
Normal file
210
resources/js/modules/lib/chart/box.js
Normal file
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Person card (box) renderer.
|
||||
*
|
||||
* Shows first + last name (middle names dropped to save space).
|
||||
* Profile picture displayed if available, otherwise a gendered silhouette.
|
||||
* Hover shows a rich bio card with dates, places, occupation, age.
|
||||
*/
|
||||
import { attachHoverBioCard } from "./overlay.js";
|
||||
|
||||
/**
|
||||
* Render a person card as an SVG group.
|
||||
*
|
||||
* @param {d3.Selection} parent - The parent SVG group to append to
|
||||
* @param {object} person - { x, y, id, isMain, data: { gender, "first name", ... } }
|
||||
* @param {object} config
|
||||
* @param {Function} onClick - Click handler receiving { id, data }
|
||||
* @param {string} containerSelector - Selector for the chart container (for tooltip positioning)
|
||||
* @returns {d3.Selection}
|
||||
*/
|
||||
export function renderPersonCard(parent, person, config, onClick, containerSelector) {
|
||||
const data = person.data;
|
||||
const w = config.cardWidth;
|
||||
const h = config.cardHeight;
|
||||
|
||||
const sexClass = `sex-${(data.gender || "u").toLowerCase()}`;
|
||||
const rootClass = person.isMain ? "is-root" : "";
|
||||
|
||||
const g = parent
|
||||
.append("g")
|
||||
.attr("class", `person-card ${sexClass} ${rootClass}`.trim())
|
||||
.attr("transform", `translate(${person.x - w / 2}, ${person.y - h / 2})`)
|
||||
.style("cursor", "pointer")
|
||||
.on("click", (event) => {
|
||||
event.stopPropagation();
|
||||
onClick({ id: person.id, data });
|
||||
});
|
||||
|
||||
// Card background
|
||||
g.append("rect")
|
||||
.attr("width", w)
|
||||
.attr("height", h)
|
||||
.attr("rx", 8)
|
||||
.attr("ry", 8);
|
||||
|
||||
// Photo area (left side)
|
||||
const photoSize = 50;
|
||||
const photoX = 8;
|
||||
const photoY = (h - photoSize) / 2;
|
||||
const textXOffset = photoX + photoSize + 10;
|
||||
|
||||
// Clip path for circular photo
|
||||
const clipId = `clip-${person.id}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
g.append("clipPath")
|
||||
.attr("id", clipId)
|
||||
.append("circle")
|
||||
.attr("cx", photoX + photoSize / 2)
|
||||
.attr("cy", photoY + photoSize / 2)
|
||||
.attr("r", photoSize / 2 - 2);
|
||||
|
||||
if (data.avatar) {
|
||||
// Profile picture
|
||||
g.append("image")
|
||||
.attr("href", data.avatar)
|
||||
.attr("x", photoX)
|
||||
.attr("y", photoY)
|
||||
.attr("width", photoSize)
|
||||
.attr("height", photoSize)
|
||||
.attr("preserveAspectRatio", "xMidYMid slice")
|
||||
.attr("clip-path", `url(#${clipId})`);
|
||||
} else {
|
||||
// Silhouette placeholder circle
|
||||
g.append("circle")
|
||||
.attr("cx", photoX + photoSize / 2)
|
||||
.attr("cy", photoY + photoSize / 2)
|
||||
.attr("r", photoSize / 2 - 2)
|
||||
.attr("class", "photo-placeholder");
|
||||
|
||||
// Simple silhouette icon
|
||||
const cx = photoX + photoSize / 2;
|
||||
const cy = photoY + photoSize / 2;
|
||||
// Head
|
||||
g.append("circle")
|
||||
.attr("cx", cx)
|
||||
.attr("cy", cy - 6)
|
||||
.attr("r", 8)
|
||||
.attr("class", "silhouette");
|
||||
// Body
|
||||
g.append("ellipse")
|
||||
.attr("cx", cx)
|
||||
.attr("cy", cy + 14)
|
||||
.attr("rx", 12)
|
||||
.attr("ry", 9)
|
||||
.attr("class", "silhouette");
|
||||
}
|
||||
|
||||
// Name: first + last (drop middle names)
|
||||
const firstName = data["first name"] || "";
|
||||
const lastName = data["last name"] || "";
|
||||
const displayName = formatDisplayName(firstName, lastName, data.fullName);
|
||||
const maxTextWidth = w - textXOffset - 8;
|
||||
|
||||
g.append("text")
|
||||
.attr("class", "person-name")
|
||||
.attr("x", textXOffset)
|
||||
.attr("y", h / 2 - 10)
|
||||
.text(truncateText(displayName, maxTextWidth));
|
||||
|
||||
// Dates line
|
||||
const dates = formatDates(data.birthYear, data.deathYear, data.isDead);
|
||||
if (dates) {
|
||||
g.append("text")
|
||||
.attr("class", "person-dates")
|
||||
.attr("x", textXOffset)
|
||||
.attr("y", h / 2 + 6)
|
||||
.text(dates);
|
||||
}
|
||||
|
||||
// Occupation as a third line
|
||||
const subtitle = data.occupation || "";
|
||||
if (subtitle) {
|
||||
g.append("text")
|
||||
.attr("class", "person-subtitle")
|
||||
.attr("x", textXOffset)
|
||||
.attr("y", h / 2 + 20)
|
||||
.text(truncateText(subtitle, maxTextWidth));
|
||||
}
|
||||
|
||||
// "More ancestors" indicator — two small parent boxes at top-right
|
||||
if (data.hasMoreAncestors) {
|
||||
const ig = g.append("g").attr("class", "more-ancestors-indicator");
|
||||
|
||||
// Positioning: top-right of card, protruding above
|
||||
const bw = 10; // mini box width
|
||||
const bh = 7; // mini box height
|
||||
const gap = 4; // gap between boxes
|
||||
const cx = w - 25; // center X of the indicator
|
||||
const topY = -14; // top of mini boxes (above card)
|
||||
|
||||
const leftX = cx - gap / 2 - bw;
|
||||
const rightX = cx + gap / 2;
|
||||
|
||||
// Two small rectangles (parents)
|
||||
ig.append("rect")
|
||||
.attr("x", leftX).attr("y", topY)
|
||||
.attr("width", bw).attr("height", bh)
|
||||
.attr("rx", 2).attr("ry", 2);
|
||||
ig.append("rect")
|
||||
.attr("x", rightX).attr("y", topY)
|
||||
.attr("width", bw).attr("height", bh)
|
||||
.attr("rx", 2).attr("ry", 2);
|
||||
|
||||
// Horizontal bar connecting the two boxes at their bottom center
|
||||
const barY = topY + bh;
|
||||
ig.append("line")
|
||||
.attr("x1", leftX + bw / 2).attr("y1", barY)
|
||||
.attr("x2", rightX + bw / 2).attr("y2", barY);
|
||||
|
||||
// Vertical line from bar center down to card top edge
|
||||
ig.append("line")
|
||||
.attr("x1", cx).attr("y1", barY)
|
||||
.attr("x2", cx).attr("y2", 0);
|
||||
}
|
||||
|
||||
// Attach hover bio card
|
||||
if (containerSelector) {
|
||||
attachHoverBioCard(g, data, containerSelector);
|
||||
}
|
||||
|
||||
return g;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format display name: first name + last name, dropping middle names.
|
||||
* Handles GEDCOM placeholders: @N.N. = unknown name, @P.N. = unknown given name
|
||||
*/
|
||||
function formatDisplayName(firstName, lastName, fullName) {
|
||||
// Clean GEDCOM unknown-name placeholders
|
||||
const cleanFirst = firstName && !firstName.match(/^@[A-Z]\.N\.$/) ? firstName : "";
|
||||
const cleanLast = lastName && !lastName.match(/^@[A-Z]\.N\.$/) ? lastName : "";
|
||||
|
||||
if (!cleanFirst && !cleanLast) {
|
||||
// Also clean fullName of @N.N. patterns
|
||||
const cleanFull = fullName ? fullName.replace(/@[A-Z]\.N\./g, "\u2026").trim() : "";
|
||||
return cleanFull || "???";
|
||||
}
|
||||
|
||||
// Take only the first given name (drop middle names)
|
||||
const firstOnly = cleanFirst ? cleanFirst.split(/\s+/)[0] : "";
|
||||
|
||||
if (firstOnly && cleanLast) {
|
||||
return `${firstOnly} ${cleanLast}`;
|
||||
}
|
||||
|
||||
return firstOnly || cleanLast || "???";
|
||||
}
|
||||
|
||||
function truncateText(text, maxWidth) {
|
||||
// ~7px per character at 12px font
|
||||
const maxChars = Math.floor(maxWidth / 7);
|
||||
if (!text || text.length <= maxChars) return text || "";
|
||||
return text.substring(0, maxChars - 1) + "\u2026";
|
||||
}
|
||||
|
||||
function formatDates(birth, death, isDead) {
|
||||
if (!birth && !death) return "";
|
||||
if (birth && death) return `${birth}\u2013${death}`;
|
||||
if (birth && isDead) return `${birth}\u2013?`;
|
||||
if (birth) return `* ${birth}`;
|
||||
return `\u2020 ${death}`;
|
||||
}
|
||||
145
resources/js/modules/lib/chart/overlay.js
Normal file
145
resources/js/modules/lib/chart/overlay.js
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Bio card tooltip on hover.
|
||||
*
|
||||
* Shows: full name, profile photo, birth, baptism, marriage, death,
|
||||
* occupation, residence, current age (if alive) or age at death.
|
||||
*/
|
||||
import { select } from "../d3.js";
|
||||
|
||||
let activeTooltip = null;
|
||||
let hideTimer = null;
|
||||
|
||||
/**
|
||||
* Show a bio card tooltip for a person.
|
||||
*
|
||||
* @param {object} data - Person data
|
||||
* @param {SVGElement} cardElement - The SVG card group element
|
||||
* @param {string} containerSelector
|
||||
*/
|
||||
export function showBioCard(data, cardElement, containerSelector) {
|
||||
hideTooltip();
|
||||
|
||||
const container = select(containerSelector);
|
||||
const containerRect = container.node().getBoundingClientRect();
|
||||
const cardRect = cardElement.getBoundingClientRect();
|
||||
|
||||
// Position tooltip below the card, centered
|
||||
const left = cardRect.left - containerRect.left + cardRect.width / 2;
|
||||
const top = cardRect.bottom - containerRect.top + 8;
|
||||
|
||||
const tooltip = container
|
||||
.append("div")
|
||||
.attr("class", "bio-card")
|
||||
.style("left", `${left}px`)
|
||||
.style("top", `${top}px`)
|
||||
.style("transform", "translateX(-50%)")
|
||||
.on("mouseenter", () => clearTimeout(hideTimer))
|
||||
.on("mouseleave", () => scheduleHide());
|
||||
|
||||
// Header: photo + name
|
||||
const header = tooltip.append("div").attr("class", "bio-header");
|
||||
|
||||
if (data.avatar) {
|
||||
header
|
||||
.append("img")
|
||||
.attr("src", data.avatar)
|
||||
.attr("alt", data.fullName || "")
|
||||
.attr("class", "bio-photo");
|
||||
}
|
||||
|
||||
const headerText = header.append("div").attr("class", "bio-header-text");
|
||||
headerText.append("div").attr("class", "bio-name").text(data.fullName || "???");
|
||||
|
||||
// Age
|
||||
const ageText = computeAge(data);
|
||||
if (ageText) {
|
||||
headerText.append("div").attr("class", "bio-age").text(ageText);
|
||||
}
|
||||
|
||||
// Facts list
|
||||
const facts = tooltip.append("div").attr("class", "bio-facts");
|
||||
|
||||
addFact(facts, "Born", data.birthDate, data.birthPlace);
|
||||
addFact(facts, "Baptism", data.baptismDate);
|
||||
addFact(facts, "Marriage", data.marriageDate);
|
||||
addFact(facts, "Died", data.deathDate, data.deathPlace);
|
||||
addFact(facts, "Occupation", data.occupation);
|
||||
addFact(facts, "Residence", data.residence);
|
||||
|
||||
// Link to profile
|
||||
tooltip
|
||||
.append("a")
|
||||
.attr("href", data.url)
|
||||
.attr("class", "bio-link")
|
||||
.text("View profile \u2192");
|
||||
|
||||
activeTooltip = tooltip;
|
||||
}
|
||||
|
||||
function addFact(container, label, value, place) {
|
||||
if (!value && !place) return;
|
||||
|
||||
const row = container.append("div").attr("class", "bio-fact");
|
||||
row.append("span").attr("class", "bio-fact-label").text(label);
|
||||
|
||||
let display = value || "";
|
||||
if (place) {
|
||||
display += display ? `, ${place}` : place;
|
||||
}
|
||||
row.append("span").attr("class", "bio-fact-value").text(display);
|
||||
}
|
||||
|
||||
function computeAge(data) {
|
||||
if (!data.birthYear) return "";
|
||||
|
||||
const birthYear = parseInt(data.birthYear, 10);
|
||||
if (isNaN(birthYear)) return "";
|
||||
|
||||
if (data.isDead) {
|
||||
if (data.deathYear) {
|
||||
const deathYear = parseInt(data.deathYear, 10);
|
||||
if (!isNaN(deathYear)) {
|
||||
const age = deathYear - birthYear;
|
||||
return `Died at age ${age}`;
|
||||
}
|
||||
}
|
||||
return "Deceased";
|
||||
}
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const age = currentYear - birthYear;
|
||||
return `Age ~${age}`;
|
||||
}
|
||||
|
||||
function scheduleHide() {
|
||||
hideTimer = setTimeout(hideTooltip, 300);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the active tooltip.
|
||||
*/
|
||||
export function hideTooltip() {
|
||||
clearTimeout(hideTimer);
|
||||
if (activeTooltip) {
|
||||
activeTooltip.remove();
|
||||
activeTooltip = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach hover behavior to a person card group.
|
||||
*
|
||||
* @param {d3.Selection} cardGroup - The SVG <g> for the person card
|
||||
* @param {object} data - Person data
|
||||
* @param {string} containerSelector
|
||||
*/
|
||||
export function attachHoverBioCard(cardGroup, data, containerSelector) {
|
||||
cardGroup
|
||||
.on("mouseenter", function () {
|
||||
clearTimeout(hideTimer);
|
||||
showBioCard(data, this, containerSelector);
|
||||
})
|
||||
.on("mouseleave", () => {
|
||||
scheduleHide();
|
||||
});
|
||||
}
|
||||
60
resources/js/modules/lib/chart/svg.js
Normal file
60
resources/js/modules/lib/chart/svg.js
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* SVG container setup.
|
||||
*/
|
||||
import { select } from "../d3.js";
|
||||
|
||||
/**
|
||||
* Create the main SVG element within the container.
|
||||
*
|
||||
* @param {string} selector - CSS selector for the container element
|
||||
* @returns {d3.Selection} The SVG selection
|
||||
*/
|
||||
export function createSvg(selector) {
|
||||
const container = select(selector);
|
||||
const { width, height } = container.node().getBoundingClientRect();
|
||||
|
||||
const svg = container
|
||||
.append("svg")
|
||||
.attr("width", width)
|
||||
.attr("height", height)
|
||||
.attr("viewBox", `0 0 ${width} ${height}`);
|
||||
|
||||
// Main group that will be transformed by zoom/pan
|
||||
svg.append("g").attr("class", "full-diagram-canvas");
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the canvas group (the zoomable/pannable layer).
|
||||
*
|
||||
* @param {d3.Selection} svg
|
||||
* @returns {d3.Selection}
|
||||
*/
|
||||
export function getCanvas(svg) {
|
||||
return svg.select("g.full-diagram-canvas");
|
||||
}
|
||||
|
||||
/**
|
||||
* Center the canvas on the root node.
|
||||
*
|
||||
* @param {d3.Selection} svg
|
||||
* @param {d3.ZoomBehavior} zoomBehavior
|
||||
*/
|
||||
export function centerOnRoot(svg, zoomBehavior) {
|
||||
const { width, height } = svg.node().getBoundingClientRect();
|
||||
|
||||
const initialTransform = {
|
||||
x: width / 2,
|
||||
y: height / 2,
|
||||
k: 1,
|
||||
};
|
||||
|
||||
svg.call(
|
||||
zoomBehavior.transform,
|
||||
() =>
|
||||
new DOMMatrix()
|
||||
.translate(initialTransform.x, initialTransform.y)
|
||||
.scale(initialTransform.k)
|
||||
);
|
||||
}
|
||||
72
resources/js/modules/lib/chart/zoom.js
Normal file
72
resources/js/modules/lib/chart/zoom.js
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Pan and zoom behavior via d3-zoom.
|
||||
*/
|
||||
import { zoom, zoomIdentity, select } from "../d3.js";
|
||||
import { getCanvas } from "./svg.js";
|
||||
|
||||
/**
|
||||
* Initialize zoom behavior on the SVG element.
|
||||
*
|
||||
* @param {d3.Selection} svg
|
||||
* @returns {d3.ZoomBehavior}
|
||||
*/
|
||||
export function initZoom(svg) {
|
||||
const canvas = getCanvas(svg);
|
||||
|
||||
const zoomBehavior = zoom()
|
||||
.scaleExtent([0.1, 4])
|
||||
.on("zoom", (event) => {
|
||||
canvas.attr("transform", event.transform);
|
||||
});
|
||||
|
||||
svg.call(zoomBehavior);
|
||||
|
||||
// Disable double-click zoom (we use click for navigation)
|
||||
svg.on("dblclick.zoom", null);
|
||||
|
||||
return zoomBehavior;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create zoom control buttons.
|
||||
*
|
||||
* @param {string} containerSelector
|
||||
* @param {d3.Selection} svg
|
||||
* @param {d3.ZoomBehavior} zoomBehavior
|
||||
*/
|
||||
export function createZoomControls(containerSelector, svg, zoomBehavior) {
|
||||
const container = select(containerSelector);
|
||||
|
||||
const controls = container
|
||||
.append("div")
|
||||
.attr("class", "zoom-controls");
|
||||
|
||||
controls
|
||||
.append("button")
|
||||
.attr("type", "button")
|
||||
.attr("title", "Zoom in")
|
||||
.text("+")
|
||||
.on("click", () => svg.transition().duration(300).call(zoomBehavior.scaleBy, 1.3));
|
||||
|
||||
controls
|
||||
.append("button")
|
||||
.attr("type", "button")
|
||||
.attr("title", "Zoom out")
|
||||
.text("\u2212")
|
||||
.on("click", () => svg.transition().duration(300).call(zoomBehavior.scaleBy, 0.7));
|
||||
|
||||
controls
|
||||
.append("button")
|
||||
.attr("type", "button")
|
||||
.attr("title", "Reset view")
|
||||
.text("\u21BA")
|
||||
.on("click", () => {
|
||||
const { width, height } = svg.node().getBoundingClientRect();
|
||||
svg.transition()
|
||||
.duration(500)
|
||||
.call(
|
||||
zoomBehavior.transform,
|
||||
zoomIdentity.translate(width / 2, height / 2)
|
||||
);
|
||||
});
|
||||
}
|
||||
6
resources/js/modules/lib/d3.js
vendored
Normal file
6
resources/js/modules/lib/d3.js
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Cherry-picked D3 module re-exports.
|
||||
*/
|
||||
export { select, selectAll } from "d3-selection";
|
||||
export { zoom, zoomIdentity } from "d3-zoom";
|
||||
import "d3-transition";
|
||||
472
resources/js/modules/lib/layout/elk-layout.js
Normal file
472
resources/js/modules/lib/layout/elk-layout.js
Normal file
@@ -0,0 +1,472 @@
|
||||
/**
|
||||
* ELK (Eclipse Layout Kernel) based family tree layout.
|
||||
*
|
||||
* Uses the union-node pattern with ELK's Sugiyama algorithm for
|
||||
* guaranteed overlap-free positioning. Connector lines are drawn
|
||||
* manually using clean orthogonal bus lines (not ELK's edge routing).
|
||||
*
|
||||
* Post-processing snaps all people of the same generation to the same
|
||||
* Y coordinate and repositions union nodes between generation rows.
|
||||
* Spouse-grouped node ordering keeps couples placed close together.
|
||||
*
|
||||
* Input: flat person array with rels { parents, spouses, children }
|
||||
* Output: positioned persons + orthogonal connector paths
|
||||
*/
|
||||
import ELK from "elkjs/lib/elk.bundled.js";
|
||||
|
||||
const elk = new ELK();
|
||||
|
||||
/**
|
||||
* @param {Array} persons - Flat array of { id, data, rels }
|
||||
* @param {string} mainId - Root person ID
|
||||
* @param {object} config - Card dimensions and spacing
|
||||
* @returns {Promise<LayoutResult>}
|
||||
*/
|
||||
export async function computeElkLayout(persons, mainId, config) {
|
||||
const builder = new GraphBuilder(persons, mainId, config);
|
||||
builder.build();
|
||||
const graph = builder.buildElkGraph();
|
||||
const result = await elk.layout(graph);
|
||||
return extractPositions(result, builder, config);
|
||||
}
|
||||
|
||||
// ─── Graph Builder ───────────────────────────────────────────────────
|
||||
|
||||
class GraphBuilder {
|
||||
constructor(persons, mainId, config) {
|
||||
this.config = config;
|
||||
this.personById = new Map();
|
||||
for (const p of persons) {
|
||||
this.personById.set(p.id, p);
|
||||
}
|
||||
this.mainId = mainId;
|
||||
|
||||
this.nodes = new Map(); // id → { id, type, data }
|
||||
this.edges = [];
|
||||
this.unionCounter = 0;
|
||||
|
||||
// Track which family units we've already created union nodes for
|
||||
// key = sorted parent IDs joined, value = union node id
|
||||
this.familyUnions = new Map();
|
||||
|
||||
// Generation number per person (0 = main, negative = ancestors, positive = descendants)
|
||||
this.generations = new Map();
|
||||
}
|
||||
|
||||
build() {
|
||||
// Add all persons as nodes
|
||||
for (const [id, person] of this.personById) {
|
||||
this.nodes.set(id, {
|
||||
id: id,
|
||||
type: "person",
|
||||
data: person.data,
|
||||
isMain: id === this.mainId,
|
||||
});
|
||||
}
|
||||
|
||||
// For each person, create union nodes for their family relationships
|
||||
for (const [id, person] of this.personById) {
|
||||
const parents = (person.rels.parents || []).filter((pid) =>
|
||||
this.personById.has(pid)
|
||||
);
|
||||
|
||||
if (parents.length > 0) {
|
||||
const unionId = this.getOrCreateFamilyUnion(parents);
|
||||
// union → child
|
||||
this.addEdge(unionId, id);
|
||||
}
|
||||
}
|
||||
|
||||
// Compute generation numbers via BFS from main person
|
||||
this.computeGenerations();
|
||||
}
|
||||
|
||||
/**
|
||||
* BFS from the main person to assign generation numbers.
|
||||
* Spouses get the same generation, parents get gen-1, children get gen+1.
|
||||
* Spouses are processed first to ensure they share a layer.
|
||||
*/
|
||||
computeGenerations() {
|
||||
this.generations.set(this.mainId, 0);
|
||||
const queue = [this.mainId];
|
||||
const visited = new Set([this.mainId]);
|
||||
|
||||
while (queue.length > 0) {
|
||||
const id = queue.shift();
|
||||
const gen = this.generations.get(id);
|
||||
const person = this.personById.get(id);
|
||||
if (!person) continue;
|
||||
|
||||
// Spouses = same generation (process first for consistency)
|
||||
for (const sid of person.rels.spouses || []) {
|
||||
if (!visited.has(sid) && this.personById.has(sid)) {
|
||||
this.generations.set(sid, gen);
|
||||
visited.add(sid);
|
||||
queue.push(sid);
|
||||
}
|
||||
}
|
||||
|
||||
// Parents = one generation up
|
||||
for (const pid of person.rels.parents || []) {
|
||||
if (!visited.has(pid) && this.personById.has(pid)) {
|
||||
this.generations.set(pid, gen - 1);
|
||||
visited.add(pid);
|
||||
queue.push(pid);
|
||||
}
|
||||
}
|
||||
|
||||
// Children = one generation down
|
||||
for (const cid of person.rels.children || []) {
|
||||
if (!visited.has(cid) && this.personById.has(cid)) {
|
||||
this.generations.set(cid, gen + 1);
|
||||
visited.add(cid);
|
||||
queue.push(cid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a union node for a set of parents.
|
||||
* Creates parent → union edges on first creation.
|
||||
*/
|
||||
getOrCreateFamilyUnion(parentIds) {
|
||||
const key = [...parentIds].sort().join("|");
|
||||
if (this.familyUnions.has(key)) {
|
||||
return this.familyUnions.get(key);
|
||||
}
|
||||
|
||||
const unionId = `union_${this.unionCounter++}`;
|
||||
this.nodes.set(unionId, {
|
||||
id: unionId,
|
||||
type: "union",
|
||||
data: null,
|
||||
});
|
||||
this.familyUnions.set(key, unionId);
|
||||
|
||||
// parent → union edges (high priority to keep parents close)
|
||||
for (const pid of parentIds) {
|
||||
this.addEdge(pid, unionId, 10);
|
||||
}
|
||||
|
||||
return unionId;
|
||||
}
|
||||
|
||||
addEdge(source, target, priority = 1) {
|
||||
const exists = this.edges.some(
|
||||
(e) => e.source === source && e.target === target
|
||||
);
|
||||
if (!exists) {
|
||||
this.edges.push({ source, target, priority });
|
||||
}
|
||||
}
|
||||
|
||||
buildElkGraph() {
|
||||
const w = this.config.cardWidth;
|
||||
const h = this.config.cardHeight;
|
||||
const unionSize = 2;
|
||||
|
||||
// Order person nodes with spouses adjacent for model-order awareness
|
||||
const orderedPersonIds = this._orderPersonsBySpouseGroups();
|
||||
|
||||
const elkNodes = [];
|
||||
|
||||
// Add person nodes in spouse-grouped order
|
||||
for (const id of orderedPersonIds) {
|
||||
elkNodes.push({
|
||||
id: id,
|
||||
width: w,
|
||||
height: h,
|
||||
});
|
||||
}
|
||||
|
||||
// Add union nodes
|
||||
for (const [id, node] of this.nodes) {
|
||||
if (node.type !== "union") continue;
|
||||
elkNodes.push({
|
||||
id: id,
|
||||
width: unionSize,
|
||||
height: unionSize,
|
||||
});
|
||||
}
|
||||
|
||||
const elkEdges = this.edges.map((e, i) => {
|
||||
const edge = {
|
||||
id: `e${i}`,
|
||||
sources: [e.source],
|
||||
targets: [e.target],
|
||||
};
|
||||
if (e.priority > 1) {
|
||||
edge.layoutOptions = {
|
||||
"elk.layered.priority.direction": String(e.priority),
|
||||
"elk.layered.priority.shortness": String(e.priority),
|
||||
};
|
||||
}
|
||||
return edge;
|
||||
});
|
||||
|
||||
return {
|
||||
id: "root",
|
||||
layoutOptions: {
|
||||
"elk.algorithm": "layered",
|
||||
"elk.direction": "DOWN",
|
||||
"elk.edgeRouting": "ORTHOGONAL",
|
||||
"elk.layered.spacing.nodeNodeBetweenLayers": String(
|
||||
this.config.verticalSpacing
|
||||
),
|
||||
"elk.spacing.nodeNode": String(this.config.horizontalSpacing),
|
||||
"elk.layered.spacing.edgeNodeBetweenLayers": "15",
|
||||
"elk.layered.spacing.edgeEdgeBetweenLayers": "10",
|
||||
"elk.layered.nodePlacement.strategy": "NETWORK_SIMPLEX",
|
||||
"elk.layered.crossingMinimization.strategy": "LAYER_SWEEP",
|
||||
"elk.layered.considerModelOrder.strategy": "NODES_AND_EDGES",
|
||||
"elk.separateConnectedComponents": "false",
|
||||
"elk.layered.compaction.postCompaction.strategy":
|
||||
"EDGE_LENGTH",
|
||||
},
|
||||
children: elkNodes,
|
||||
edges: elkEdges,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Order person nodes so that spouse pairs are adjacent in the input.
|
||||
* Combined with considerModelOrder, this keeps couples placed close together.
|
||||
*/
|
||||
_orderPersonsBySpouseGroups() {
|
||||
const ordered = [];
|
||||
const added = new Set();
|
||||
|
||||
for (const [id, person] of this.personById) {
|
||||
if (added.has(id)) continue;
|
||||
added.add(id);
|
||||
ordered.push(id);
|
||||
|
||||
// Add spouses immediately after this person
|
||||
const spouses = (person.rels.spouses || []).filter(
|
||||
(sid) => this.personById.has(sid) && !added.has(sid)
|
||||
);
|
||||
for (const sid of spouses) {
|
||||
added.add(sid);
|
||||
ordered.push(sid);
|
||||
}
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Extract positions & build clean connectors ──────────────────────
|
||||
|
||||
function extractPositions(elkResult, builder, config) {
|
||||
const persons = [];
|
||||
const unions = [];
|
||||
const connections = [];
|
||||
|
||||
const halfH = config.cardHeight / 2;
|
||||
|
||||
// ── Step 1: Read raw ELK positions ──
|
||||
const rawPos = new Map(); // id → { cx, cy }
|
||||
let rootX = 0,
|
||||
rootY = 0;
|
||||
|
||||
for (const elkNode of elkResult.children || []) {
|
||||
const nodeInfo = builder.nodes.get(elkNode.id);
|
||||
if (!nodeInfo) continue;
|
||||
const cx = elkNode.x + elkNode.width / 2;
|
||||
const cy = elkNode.y + elkNode.height / 2;
|
||||
rawPos.set(elkNode.id, { cx, cy });
|
||||
if (nodeInfo.isMain) {
|
||||
rootX = cx;
|
||||
rootY = cy;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 2: Snap person nodes to generation rows ──
|
||||
// Group person nodes by generation, compute median Y for each generation
|
||||
const genGroups = new Map(); // generation → [{ id, cx, cy }]
|
||||
for (const [id, pos] of rawPos) {
|
||||
const nodeInfo = builder.nodes.get(id);
|
||||
if (!nodeInfo || nodeInfo.type !== "person") continue;
|
||||
const gen = builder.generations.get(id) || 0;
|
||||
if (!genGroups.has(gen)) genGroups.set(gen, []);
|
||||
genGroups.get(gen).push({ id, ...pos });
|
||||
}
|
||||
|
||||
// For each generation, use the median Y as the canonical row Y
|
||||
const genY = new Map(); // generation → snapped Y
|
||||
for (const [gen, nodes] of genGroups) {
|
||||
const ys = nodes.map((n) => n.cy).sort((a, b) => a - b);
|
||||
const mid = Math.floor(ys.length / 2);
|
||||
const medianY =
|
||||
ys.length % 2 === 0 ? (ys[mid - 1] + ys[mid]) / 2 : ys[mid];
|
||||
genY.set(gen, medianY);
|
||||
}
|
||||
|
||||
// ── Step 3: Compute union node Y positions ──
|
||||
// Each union sits between its parent generation row and child generation row
|
||||
const unionGenY = new Map(); // unionId → snapped Y
|
||||
for (const [id, node] of builder.nodes) {
|
||||
if (node.type !== "union") continue;
|
||||
|
||||
// Find parent generation (edges INTO this union)
|
||||
let parentGen = null;
|
||||
let childGen = null;
|
||||
for (const edge of builder.edges) {
|
||||
if (edge.target === id && builder.generations.has(edge.source)) {
|
||||
parentGen = builder.generations.get(edge.source);
|
||||
}
|
||||
if (edge.source === id && builder.generations.has(edge.target)) {
|
||||
childGen = builder.generations.get(edge.target);
|
||||
}
|
||||
}
|
||||
|
||||
if (parentGen !== null && childGen !== null) {
|
||||
const pY = genY.get(parentGen);
|
||||
const cY = genY.get(childGen);
|
||||
if (pY !== undefined && cY !== undefined) {
|
||||
// Place union at: parent bottom edge + 40% of gap to child top edge
|
||||
const parentBottom = pY + halfH;
|
||||
const childTop = cY - halfH;
|
||||
unionGenY.set(id, parentBottom + (childTop - parentBottom) * 0.4);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: use raw ELK position
|
||||
const raw = rawPos.get(id);
|
||||
if (raw) unionGenY.set(id, raw.cy);
|
||||
}
|
||||
|
||||
// ── Step 4: Build final positioned nodes (centered on root) ──
|
||||
const posMap = new Map(); // id → { x, y }
|
||||
|
||||
// Recalculate rootY using the snapped generation Y
|
||||
const mainGen = builder.generations.get(builder.mainId) || 0;
|
||||
const snappedRootY = genY.get(mainGen) || rootY;
|
||||
|
||||
for (const [id, node] of builder.nodes) {
|
||||
const raw = rawPos.get(id);
|
||||
if (!raw) continue;
|
||||
|
||||
let finalY;
|
||||
if (node.type === "person") {
|
||||
const gen = builder.generations.get(id) || 0;
|
||||
finalY = (genY.get(gen) || raw.cy) - snappedRootY;
|
||||
} else {
|
||||
finalY = (unionGenY.get(id) || raw.cy) - snappedRootY;
|
||||
}
|
||||
|
||||
const finalX = raw.cx - rootX;
|
||||
posMap.set(id, { x: finalX, y: finalY });
|
||||
|
||||
if (node.type === "person") {
|
||||
persons.push({
|
||||
x: finalX,
|
||||
y: finalY,
|
||||
id: node.id,
|
||||
isMain: node.isMain,
|
||||
data: node.data,
|
||||
});
|
||||
} else {
|
||||
unions.push({ id: id, x: finalX, y: finalY });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 5: Build clean bus-line connectors ──
|
||||
const incomingToUnion = new Map();
|
||||
const outgoingFromUnion = new Map();
|
||||
|
||||
for (const edge of builder.edges) {
|
||||
const sourceInfo = builder.nodes.get(edge.source);
|
||||
const targetInfo = builder.nodes.get(edge.target);
|
||||
|
||||
if (targetInfo && targetInfo.type === "union") {
|
||||
if (!incomingToUnion.has(edge.target))
|
||||
incomingToUnion.set(edge.target, []);
|
||||
incomingToUnion.get(edge.target).push(edge.source);
|
||||
}
|
||||
|
||||
if (sourceInfo && sourceInfo.type === "union") {
|
||||
if (!outgoingFromUnion.has(edge.source))
|
||||
outgoingFromUnion.set(edge.source, []);
|
||||
outgoingFromUnion.get(edge.source).push(edge.target);
|
||||
}
|
||||
}
|
||||
|
||||
for (const union of unions) {
|
||||
const parentIds = incomingToUnion.get(union.id) || [];
|
||||
const childIds = outgoingFromUnion.get(union.id) || [];
|
||||
|
||||
const parents = parentIds
|
||||
.map((id) => posMap.get(id))
|
||||
.filter(Boolean);
|
||||
const children = childIds
|
||||
.map((id) => posMap.get(id))
|
||||
.filter(Boolean);
|
||||
|
||||
const ux = union.x;
|
||||
const uy = union.y;
|
||||
|
||||
// ── Parent → union connections ──
|
||||
if (parents.length > 0) {
|
||||
// Horizontal couple bar at union Y
|
||||
if (parents.length >= 2) {
|
||||
const xs = parents.map((p) => p.x).sort((a, b) => a - b);
|
||||
connections.push({
|
||||
path: `M ${xs[0]} ${uy} L ${xs[xs.length - 1]} ${uy}`,
|
||||
cssClass: "link couple-link",
|
||||
});
|
||||
}
|
||||
|
||||
// Vertical drop from each parent's bottom edge to couple bar Y
|
||||
for (const p of parents) {
|
||||
const bottomY = p.y + halfH;
|
||||
connections.push({
|
||||
path: `M ${p.x} ${bottomY} L ${p.x} ${uy}`,
|
||||
cssClass: "link ancestor-link",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Union → children connections ──
|
||||
if (children.length > 0) {
|
||||
// Bus Y halfway between union and children's top edge
|
||||
const childY = children[0].y;
|
||||
const busY = uy + (childY - halfH - uy) / 2;
|
||||
|
||||
// Vertical stem from union down to bus
|
||||
connections.push({
|
||||
path: `M ${ux} ${uy} L ${ux} ${busY}`,
|
||||
cssClass: "link descendant-link",
|
||||
});
|
||||
|
||||
if (children.length === 1) {
|
||||
// Single child: continue vertical line
|
||||
connections.push({
|
||||
path: `M ${children[0].x} ${busY} L ${children[0].x} ${childY - halfH}`,
|
||||
cssClass: "link descendant-link",
|
||||
});
|
||||
} else {
|
||||
// Horizontal bus spanning all children
|
||||
const xs = children
|
||||
.map((c) => c.x)
|
||||
.sort((a, b) => a - b);
|
||||
connections.push({
|
||||
path: `M ${xs[0]} ${busY} L ${xs[xs.length - 1]} ${busY}`,
|
||||
cssClass: "link descendant-link",
|
||||
});
|
||||
|
||||
// Vertical drops from bus to each child's top edge
|
||||
for (const c of children) {
|
||||
connections.push({
|
||||
path: `M ${c.x} ${busY} L ${c.x} ${childY - halfH}`,
|
||||
cssClass: "link descendant-link",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { persons, unions, connections };
|
||||
}
|
||||
457
resources/js/modules/lib/layout/graphviz-layout.js
Normal file
457
resources/js/modules/lib/layout/graphviz-layout.js
Normal file
@@ -0,0 +1,457 @@
|
||||
/**
|
||||
* Graphviz (viz.js) based family tree layout.
|
||||
*
|
||||
* Architecture: union-node pattern
|
||||
* ─────────────────────────────────
|
||||
* Each marriage/partnership is modeled as a small "union" node.
|
||||
* parent1 ──→ union ──→ child1
|
||||
* parent2 ──→ union ──→ child2
|
||||
*
|
||||
* Graphviz's "dot" algorithm (Sugiyama) then places:
|
||||
* - Each generation on its own rank
|
||||
* - Union nodes on intermediate ranks between parents and children
|
||||
* - Couples side-by-side (connected to the same union)
|
||||
* - Children centered below the union node
|
||||
*
|
||||
* The layout guarantees no overlaps and handles multiple marriages,
|
||||
* siblings, and complex family structures correctly.
|
||||
*/
|
||||
import { instance as vizInstance } from "@viz-js/viz";
|
||||
|
||||
let viz = null;
|
||||
|
||||
async function getViz() {
|
||||
if (!viz) {
|
||||
viz = await vizInstance();
|
||||
}
|
||||
return viz;
|
||||
}
|
||||
|
||||
// Graphviz uses inches; 1 inch = 72 points
|
||||
const PPI = 72;
|
||||
|
||||
/**
|
||||
* Build a Graphviz DOT graph from the tree data and compute layout.
|
||||
*
|
||||
* @param {object} rootData - The root person data from PHP
|
||||
* @param {Configuration} config
|
||||
* @returns {Promise<LayoutResult>} Positioned nodes and edges
|
||||
*/
|
||||
export async function computeGraphvizLayout(rootData, config) {
|
||||
const builder = new GraphBuilder(config);
|
||||
builder.addPersonFromData(rootData);
|
||||
const dot = builder.buildDotGraph();
|
||||
|
||||
const viz = await getViz();
|
||||
const result = viz.renderJSON(dot);
|
||||
|
||||
return extractPositions(result, builder, config);
|
||||
}
|
||||
|
||||
// ─── Graph Builder ───────────────────────────────────────────────────
|
||||
|
||||
class GraphBuilder {
|
||||
constructor(config) {
|
||||
this.config = config;
|
||||
this.nodes = new Map(); // id → { id, type, data, layer }
|
||||
this.edges = []; // { source, target }
|
||||
this.visited = new Set();
|
||||
this.unionCounter = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively add a person and all their relatives to the graph.
|
||||
*/
|
||||
addPersonFromData(data, currentLayer = 0) {
|
||||
if (this.visited.has(data.xref)) return;
|
||||
this.visited.add(data.xref);
|
||||
|
||||
this.addPersonNode(data, currentLayer);
|
||||
|
||||
// Ancestors: go up via parentFamilies
|
||||
if (data.parentFamilies && data.parentFamilies.length > 0) {
|
||||
this.addAncestorFamilies(data, currentLayer);
|
||||
}
|
||||
|
||||
// Descendants: go down
|
||||
if (data.families && data.families.length > 0) {
|
||||
this.addDescendants(data, currentLayer);
|
||||
}
|
||||
}
|
||||
|
||||
addPersonNode(data, layer) {
|
||||
if (this.nodes.has(data.xref)) return;
|
||||
this.nodes.set(data.xref, {
|
||||
id: data.xref,
|
||||
type: "person",
|
||||
data: data,
|
||||
layer: layer,
|
||||
});
|
||||
}
|
||||
|
||||
addUnionNode(layer) {
|
||||
const id = `union_${this.unionCounter++}`;
|
||||
this.nodes.set(id, {
|
||||
id: id,
|
||||
type: "union",
|
||||
data: null,
|
||||
layer: layer,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
addEdge(source, target) {
|
||||
const exists = this.edges.some(
|
||||
(e) => e.source === source && e.target === target
|
||||
);
|
||||
if (!exists) {
|
||||
this.edges.push({ source, target });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add ancestor families: each parentFamily is a FamilyNode with
|
||||
* .parents (both mother & father) and .children (siblings).
|
||||
*
|
||||
* Creates: parent → union → child (for each child including the person)
|
||||
*/
|
||||
addAncestorFamilies(personData, personLayer) {
|
||||
const parentFamilies = personData.parentFamilies;
|
||||
if (!parentFamilies || parentFamilies.length === 0) return;
|
||||
|
||||
for (const family of parentFamilies) {
|
||||
const unionLayer = personLayer - 1;
|
||||
const parentLayer = personLayer - 2;
|
||||
const unionId = this.addUnionNode(unionLayer);
|
||||
|
||||
// Union → person (this child)
|
||||
this.addEdge(unionId, personData.xref);
|
||||
|
||||
// Each parent → union, then recurse into their ancestors
|
||||
for (const parent of family.parents || []) {
|
||||
this.addPersonNode(parent, parentLayer);
|
||||
this.addEdge(parent.xref, unionId);
|
||||
|
||||
if (!this.visited.has(parent.xref)) {
|
||||
this.visited.add(parent.xref);
|
||||
if (
|
||||
parent.parentFamilies &&
|
||||
parent.parentFamilies.length > 0
|
||||
) {
|
||||
this.addAncestorFamilies(parent, parentLayer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Siblings (other children of this family) → same union
|
||||
for (const sibling of family.children || []) {
|
||||
this.addPersonNode(sibling, personLayer);
|
||||
this.addEdge(unionId, sibling.xref);
|
||||
|
||||
// Process sibling's descendants
|
||||
if (!this.visited.has(sibling.xref)) {
|
||||
this.visited.add(sibling.xref);
|
||||
if (sibling.families && sibling.families.length > 0) {
|
||||
this.addDescendants(sibling, personLayer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add descendant chain: person → union → children
|
||||
*/
|
||||
addDescendants(personData, personLayer) {
|
||||
if (!personData.families) return;
|
||||
|
||||
for (const family of personData.families) {
|
||||
const unionLayer = personLayer + 1;
|
||||
const childLayer = personLayer + 2;
|
||||
const unionId = this.addUnionNode(unionLayer);
|
||||
|
||||
// Person → union
|
||||
this.addEdge(personData.xref, unionId);
|
||||
|
||||
// Spouse → union
|
||||
if (family.spouse) {
|
||||
this.addPersonNode(family.spouse, personLayer);
|
||||
this.addEdge(family.spouse.xref, unionId);
|
||||
}
|
||||
|
||||
// Union → each child
|
||||
for (const child of family.children || []) {
|
||||
this.addPersonNode(child, childLayer);
|
||||
this.addEdge(unionId, child.xref);
|
||||
|
||||
// Recurse into child's descendants
|
||||
if (!this.visited.has(child.xref)) {
|
||||
this.visited.add(child.xref);
|
||||
if (child.families && child.families.length > 0) {
|
||||
this.addDescendants(child, childLayer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Graphviz DOT language graph.
|
||||
*/
|
||||
buildDotGraph() {
|
||||
const w = this.config.cardWidth / PPI;
|
||||
const h = this.config.cardHeight / PPI;
|
||||
const nodesep = this.config.horizontalSpacing / PPI;
|
||||
const ranksep = this.config.verticalSpacing / PPI;
|
||||
|
||||
let dot = "digraph G {\n";
|
||||
dot += " rankdir=TB;\n";
|
||||
dot += ` nodesep=${nodesep.toFixed(3)};\n`;
|
||||
dot += ` ranksep=${ranksep.toFixed(3)};\n`;
|
||||
dot += " splines=none;\n";
|
||||
dot += " ordering=out;\n";
|
||||
dot += "\n";
|
||||
|
||||
// Add nodes
|
||||
for (const [id, node] of this.nodes) {
|
||||
// Escape quotes in IDs
|
||||
const safeId = id.replace(/"/g, '\\"');
|
||||
if (node.type === "person") {
|
||||
dot += ` "${safeId}" [shape=box, fixedsize=true, width=${w.toFixed(3)}, height=${h.toFixed(3)}];\n`;
|
||||
} else {
|
||||
dot += ` "${safeId}" [shape=point, width=0.01, height=0.01];\n`;
|
||||
}
|
||||
}
|
||||
|
||||
dot += "\n";
|
||||
|
||||
// Add edges
|
||||
for (const edge of this.edges) {
|
||||
const src = edge.source.replace(/"/g, '\\"');
|
||||
const tgt = edge.target.replace(/"/g, '\\"');
|
||||
dot += ` "${src}" -> "${tgt}";\n`;
|
||||
}
|
||||
|
||||
dot += "\n";
|
||||
|
||||
// Add rank constraints to group nodes at the same layer
|
||||
const layerGroups = new Map();
|
||||
for (const [id, node] of this.nodes) {
|
||||
const layer = node.layer;
|
||||
if (!layerGroups.has(layer)) layerGroups.set(layer, []);
|
||||
layerGroups.get(layer).push(id);
|
||||
}
|
||||
|
||||
for (const [, ids] of layerGroups) {
|
||||
if (ids.length > 1) {
|
||||
const quoted = ids
|
||||
.map((id) => `"${id.replace(/"/g, '\\"')}"`)
|
||||
.join("; ");
|
||||
dot += ` { rank=same; ${quoted}; }\n`;
|
||||
}
|
||||
}
|
||||
|
||||
dot += "}\n";
|
||||
return dot;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Extract results ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse a Graphviz "x,y" position string into {x, y} in pixels.
|
||||
* Graphviz Y-axis goes bottom-to-top, so we negate Y for SVG (top-to-bottom).
|
||||
*/
|
||||
function parsePos(posStr) {
|
||||
const parts = posStr.split(",");
|
||||
return { x: parseFloat(parts[0]), y: -parseFloat(parts[1]) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract node positions from Graphviz and build family connections ourselves.
|
||||
*
|
||||
* We IGNORE Graphviz's edge routing entirely. Instead we use node positions
|
||||
* and the graph structure to draw clean family-tree connectors:
|
||||
*
|
||||
* Parent1 Parent2
|
||||
* | |
|
||||
* +----+-----+ ← horizontal couple bar
|
||||
* |
|
||||
* ------+------ ← horizontal children bus
|
||||
* | | | |
|
||||
* C1 C2 C3 C4
|
||||
*
|
||||
* This gives merged, clean orthogonal lines at consistent heights.
|
||||
*
|
||||
* @returns {LayoutResult}
|
||||
*/
|
||||
function extractPositions(gvResult, builder, config) {
|
||||
const persons = [];
|
||||
const unions = [];
|
||||
const connections = []; // family connections, not raw edges
|
||||
|
||||
// Map node names to their Graphviz positions
|
||||
const nodePositions = new Map(); // name → { x, y }
|
||||
|
||||
let rootX = 0;
|
||||
let rootY = 0;
|
||||
|
||||
// First pass: collect all positions, find root
|
||||
for (const obj of gvResult.objects || []) {
|
||||
if (!obj.name || !obj.pos) continue;
|
||||
const nodeInfo = builder.nodes.get(obj.name);
|
||||
if (!nodeInfo) continue;
|
||||
|
||||
const pos = parsePos(obj.pos);
|
||||
nodePositions.set(obj.name, pos);
|
||||
|
||||
if (
|
||||
nodeInfo.type === "person" &&
|
||||
nodeInfo.data &&
|
||||
nodeInfo.data.isRoot
|
||||
) {
|
||||
rootX = pos.x;
|
||||
rootY = pos.y;
|
||||
}
|
||||
}
|
||||
|
||||
const halfW = config.cardWidth / 2;
|
||||
const halfH = config.cardHeight / 2;
|
||||
|
||||
// Second pass: build positioned nodes centered on root
|
||||
for (const [name, pos] of nodePositions) {
|
||||
const nodeInfo = builder.nodes.get(name);
|
||||
if (!nodeInfo) continue;
|
||||
|
||||
const cx = pos.x - rootX;
|
||||
const cy = pos.y - rootY;
|
||||
|
||||
if (nodeInfo.type === "person") {
|
||||
persons.push({
|
||||
x: cx,
|
||||
y: cy,
|
||||
data: nodeInfo.data,
|
||||
});
|
||||
} else {
|
||||
unions.push({
|
||||
id: name,
|
||||
x: cx,
|
||||
y: cy,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Third pass: build family connections from graph structure.
|
||||
// For each union node, find its parents (edges INTO it) and
|
||||
// children (edges OUT of it), then build connector paths.
|
||||
const incomingToUnion = new Map(); // unionId → [nodeId, ...]
|
||||
const outgoingFromUnion = new Map(); // unionId → [nodeId, ...]
|
||||
|
||||
for (const edge of builder.edges) {
|
||||
const sourceInfo = builder.nodes.get(edge.source);
|
||||
const targetInfo = builder.nodes.get(edge.target);
|
||||
|
||||
if (targetInfo && targetInfo.type === "union") {
|
||||
// person → union (parent/spouse)
|
||||
if (!incomingToUnion.has(edge.target))
|
||||
incomingToUnion.set(edge.target, []);
|
||||
incomingToUnion.get(edge.target).push(edge.source);
|
||||
}
|
||||
|
||||
if (sourceInfo && sourceInfo.type === "union") {
|
||||
// union → person (child)
|
||||
if (!outgoingFromUnion.has(edge.source))
|
||||
outgoingFromUnion.set(edge.source, []);
|
||||
outgoingFromUnion.get(edge.source).push(edge.target);
|
||||
}
|
||||
}
|
||||
|
||||
// For each union, generate clean family-tree connector paths
|
||||
for (const [unionId, union] of unions.map((u) => [u.id, u])) {
|
||||
const parents = (incomingToUnion.get(unionId) || [])
|
||||
.map((id) => {
|
||||
const pos = nodePositions.get(id);
|
||||
return pos
|
||||
? { id, x: pos.x - rootX, y: pos.y - rootY }
|
||||
: null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
const children = (outgoingFromUnion.get(unionId) || [])
|
||||
.map((id) => {
|
||||
const pos = nodePositions.get(id);
|
||||
return pos
|
||||
? { id, x: pos.x - rootX, y: pos.y - rootY }
|
||||
: null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
const ux = union.x;
|
||||
const uy = union.y;
|
||||
|
||||
// --- Parent-to-union connections ---
|
||||
// Each parent drops a vertical line from card bottom to the union Y,
|
||||
// then a horizontal bar connects them at union Y.
|
||||
if (parents.length > 0) {
|
||||
// Horizontal couple bar at union Y
|
||||
if (parents.length >= 2) {
|
||||
const xs = parents.map((p) => p.x).sort((a, b) => a - b);
|
||||
connections.push({
|
||||
path: `M ${xs[0]} ${uy} L ${xs[xs.length - 1]} ${uy}`,
|
||||
cssClass: "link couple-link",
|
||||
});
|
||||
}
|
||||
|
||||
// Vertical drops from each parent's bottom edge to couple bar
|
||||
for (const p of parents) {
|
||||
const bottomY = p.y + halfH;
|
||||
connections.push({
|
||||
path: `M ${p.x} ${bottomY} L ${p.x} ${uy}`,
|
||||
cssClass: "link ancestor-link",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Union-to-children connections ---
|
||||
// Vertical line from union down to bus Y, horizontal bus spanning
|
||||
// all children, then vertical drops from bus to each child's top.
|
||||
if (children.length > 0) {
|
||||
// Bus Y is halfway between union and the first child row
|
||||
const childY = children[0].y;
|
||||
const busY = uy + (childY - halfH - uy) / 2;
|
||||
|
||||
// Vertical stem from union (or couple bar) down to bus
|
||||
connections.push({
|
||||
path: `M ${ux} ${uy} L ${ux} ${busY}`,
|
||||
cssClass: "link descendant-link",
|
||||
});
|
||||
|
||||
if (children.length === 1) {
|
||||
// Single child: just continue the vertical line
|
||||
connections.push({
|
||||
path: `M ${children[0].x} ${busY} L ${children[0].x} ${childY - halfH}`,
|
||||
cssClass: "link descendant-link",
|
||||
});
|
||||
} else {
|
||||
// Horizontal bus spanning all children
|
||||
const xs = children
|
||||
.map((c) => c.x)
|
||||
.sort((a, b) => a - b);
|
||||
connections.push({
|
||||
path: `M ${xs[0]} ${busY} L ${xs[xs.length - 1]} ${busY}`,
|
||||
cssClass: "link descendant-link",
|
||||
});
|
||||
|
||||
// Vertical drops from bus to each child's top edge
|
||||
for (const c of children) {
|
||||
connections.push({
|
||||
path: `M ${c.x} ${busY} L ${c.x} ${childY - halfH}`,
|
||||
cssClass: "link descendant-link",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { persons, unions, connections };
|
||||
}
|
||||
31
resources/js/modules/lib/tree/ancestor-tree.js
Normal file
31
resources/js/modules/lib/tree/ancestor-tree.js
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Ancestor tree rendering — draws ancestor nodes and links upward from root.
|
||||
*/
|
||||
import { renderPersonCard } from "../chart/box.js";
|
||||
import { drawElbowLink } from "./link-drawer.js";
|
||||
|
||||
/**
|
||||
* Render the ancestor tree nodes and links.
|
||||
*
|
||||
* @param {d3.Selection} canvas - The SVG canvas group
|
||||
* @param {Array} nodes - Ancestor hierarchy nodes (from computeLayout)
|
||||
* @param {Array} links - Ancestor hierarchy links
|
||||
* @param {Configuration} config
|
||||
* @param {Function} onNodeClick
|
||||
* @param {string} containerSelector
|
||||
*/
|
||||
export function renderAncestorTree(canvas, nodes, links, config, onNodeClick, containerSelector) {
|
||||
// Draw links first (behind nodes)
|
||||
const linkGroup = canvas.append("g").attr("class", "ancestor-links");
|
||||
|
||||
for (const link of links) {
|
||||
drawElbowLink(linkGroup, link.source, link.target, "ancestor-link", config);
|
||||
}
|
||||
|
||||
// Draw nodes
|
||||
const nodeGroup = canvas.append("g").attr("class", "ancestor-nodes");
|
||||
|
||||
for (const node of nodes) {
|
||||
renderPersonCard(nodeGroup, node, config, onNodeClick, containerSelector);
|
||||
}
|
||||
}
|
||||
150
resources/js/modules/lib/tree/descendant-tree.js
Normal file
150
resources/js/modules/lib/tree/descendant-tree.js
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Descendant tree rendering.
|
||||
*
|
||||
* Key design: children descend from *both* parents. A horizontal connector
|
||||
* joins the couple, and the vertical drop to children originates from the
|
||||
* midpoint of that connector — not from a single parent.
|
||||
*
|
||||
* Multiple spouses are placed alternating left/right with increasing distance
|
||||
* to avoid overlap between family branches.
|
||||
*/
|
||||
import { renderPersonCard } from "../chart/box.js";
|
||||
import { computeSpouseOffset, SPOUSE_GAP } from "./spouse-util.js";
|
||||
|
||||
/**
|
||||
* Render the descendant tree: nodes, spouse nodes, and couple-centered links.
|
||||
*/
|
||||
export function renderDescendantTree(canvas, nodes, _links, rootData, config, onNodeClick, containerSelector) {
|
||||
const linkGroup = canvas.append("g").attr("class", "descendant-links");
|
||||
const nodeGroup = canvas.append("g").attr("class", "descendant-nodes");
|
||||
|
||||
// Build a map of xref → D3 node position for all descendant nodes + root
|
||||
const posMap = new Map();
|
||||
posMap.set(rootData.xref, { x: 0, y: 0 });
|
||||
for (const node of nodes) {
|
||||
posMap.set(node.data.xref, { x: node.x, y: node.y });
|
||||
}
|
||||
|
||||
// Render all descendant person cards
|
||||
for (const node of nodes) {
|
||||
renderPersonCard(nodeGroup, node, config, onNodeClick, containerSelector);
|
||||
}
|
||||
|
||||
// Render spouses and couple-centered links at every level (including root)
|
||||
const allPersons = [rootData, ...nodes.map((n) => n.data)];
|
||||
for (const person of allPersons) {
|
||||
renderCoupleLinks(linkGroup, nodeGroup, person, posMap, config, onNodeClick, containerSelector);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* For a person with families, render each spouse and draw the
|
||||
* couple → children connector.
|
||||
*
|
||||
* Multiple spouses alternate left (odd index) / right (even index)
|
||||
* with increasing distance.
|
||||
*/
|
||||
function renderCoupleLinks(linkGroup, nodeGroup, personData, posMap, config, onNodeClick, containerSelector) {
|
||||
if (!personData.families || personData.families.length === 0) return;
|
||||
|
||||
const personPos = posMap.get(personData.xref);
|
||||
if (!personPos) return;
|
||||
|
||||
const w = config.cardWidth;
|
||||
const h = config.cardHeight;
|
||||
const halfH = h / 2;
|
||||
|
||||
personData.families.forEach((family, familyIndex) => {
|
||||
// Alternate spouse placement: first right, second left, third further right, etc.
|
||||
const spouseOffset = computeSpouseOffset(familyIndex, w, SPOUSE_GAP);
|
||||
|
||||
let spousePos = null;
|
||||
|
||||
if (family.spouse) {
|
||||
spousePos = {
|
||||
x: personPos.x + spouseOffset,
|
||||
y: personPos.y,
|
||||
};
|
||||
|
||||
// Render spouse card
|
||||
renderPersonCard(
|
||||
nodeGroup,
|
||||
{ x: spousePos.x, y: spousePos.y, data: family.spouse },
|
||||
config,
|
||||
onNodeClick,
|
||||
containerSelector
|
||||
);
|
||||
|
||||
// Horizontal connector between the couple (edge-to-edge)
|
||||
const leftX = Math.min(personPos.x, spousePos.x);
|
||||
const rightX = Math.max(personPos.x, spousePos.x);
|
||||
|
||||
linkGroup
|
||||
.append("line")
|
||||
.attr("class", "link spouse-link")
|
||||
.attr("x1", leftX + w / 2)
|
||||
.attr("y1", personPos.y)
|
||||
.attr("x2", rightX - w / 2)
|
||||
.attr("y2", personPos.y);
|
||||
}
|
||||
|
||||
// Collect children positions for this family
|
||||
const childPositions = [];
|
||||
for (const child of family.children || []) {
|
||||
const childPos = posMap.get(child.xref);
|
||||
if (childPos) {
|
||||
childPositions.push(childPos);
|
||||
}
|
||||
}
|
||||
|
||||
if (childPositions.length === 0) return;
|
||||
|
||||
// Couple midpoint X (centered between parents)
|
||||
const coupleX = spousePos
|
||||
? (personPos.x + spousePos.x) / 2
|
||||
: personPos.x;
|
||||
|
||||
// Y coordinates: bottom of parent card → top of child card
|
||||
const parentBottomY = personPos.y + halfH;
|
||||
const childTopY = childPositions[0].y - halfH;
|
||||
|
||||
// Horizontal rail sits 40% of the way down from parent to child
|
||||
const railY = parentBottomY + (childTopY - parentBottomY) * 0.4;
|
||||
|
||||
// 1. Vertical line: couple bottom → rail
|
||||
linkGroup
|
||||
.append("line")
|
||||
.attr("class", "link descendant-link")
|
||||
.attr("x1", coupleX)
|
||||
.attr("y1", parentBottomY)
|
||||
.attr("x2", coupleX)
|
||||
.attr("y2", railY);
|
||||
|
||||
// 2. Horizontal rail spanning all children
|
||||
const xs = childPositions.map((c) => c.x);
|
||||
const minX = Math.min(coupleX, ...xs);
|
||||
const maxX = Math.max(coupleX, ...xs);
|
||||
|
||||
linkGroup
|
||||
.append("line")
|
||||
.attr("class", "link descendant-link")
|
||||
.attr("x1", minX)
|
||||
.attr("y1", railY)
|
||||
.attr("x2", maxX)
|
||||
.attr("y2", railY);
|
||||
|
||||
// 3. Vertical drops: rail → top of each child card
|
||||
for (const cp of childPositions) {
|
||||
linkGroup
|
||||
.append("line")
|
||||
.attr("class", "link descendant-link")
|
||||
.attr("x1", cp.x)
|
||||
.attr("y1", railY)
|
||||
.attr("x2", cp.x)
|
||||
.attr("y2", childTopY);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Re-export from shared utility
|
||||
export { computeSpouseOffset } from "./spouse-util.js";
|
||||
83
resources/js/modules/lib/tree/family-connector.js
Normal file
83
resources/js/modules/lib/tree/family-connector.js
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Family connector utilities.
|
||||
*
|
||||
* Draws special connectors between spouses and from couples to children.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Draw a family connector: horizontal line between spouses,
|
||||
* then vertical drop to children.
|
||||
*
|
||||
* @param {d3.Selection} group
|
||||
* @param {object} parent1 - First parent position {x, y}
|
||||
* @param {object} parent2 - Second parent position {x, y}
|
||||
* @param {Array} children - Child positions [{x, y}, ...]
|
||||
* @param {Configuration} config
|
||||
*/
|
||||
export function drawFamilyConnector(group, parent1, parent2, children, config) {
|
||||
const halfHeight = config.cardHeight / 2;
|
||||
|
||||
// Horizontal line between spouses
|
||||
if (parent2) {
|
||||
group
|
||||
.append("line")
|
||||
.attr("class", "link spouse-link")
|
||||
.attr("x1", parent1.x)
|
||||
.attr("y1", parent1.y)
|
||||
.attr("x2", parent2.x)
|
||||
.attr("y2", parent2.y)
|
||||
.attr("stroke", "#d4a87b")
|
||||
.attr("stroke-width", 2);
|
||||
}
|
||||
|
||||
if (children.length === 0) return;
|
||||
|
||||
// Midpoint between parents (or just parent1 if single parent)
|
||||
const coupleX = parent2 ? (parent1.x + parent2.x) / 2 : parent1.x;
|
||||
const coupleY = parent1.y + halfHeight;
|
||||
|
||||
// Vertical drop from couple midpoint
|
||||
const childrenY = children[0].y - halfHeight;
|
||||
const midY = (coupleY + childrenY) / 2;
|
||||
|
||||
group
|
||||
.append("line")
|
||||
.attr("class", "link descendant-link")
|
||||
.attr("x1", coupleX)
|
||||
.attr("y1", coupleY)
|
||||
.attr("x2", coupleX)
|
||||
.attr("y2", midY);
|
||||
|
||||
if (children.length === 1) {
|
||||
// Single child — straight line down
|
||||
group
|
||||
.append("line")
|
||||
.attr("class", "link descendant-link")
|
||||
.attr("x1", coupleX)
|
||||
.attr("y1", midY)
|
||||
.attr("x2", children[0].x)
|
||||
.attr("y2", childrenY);
|
||||
} else {
|
||||
// Multiple children — horizontal rail
|
||||
const minX = Math.min(...children.map((c) => c.x));
|
||||
const maxX = Math.max(...children.map((c) => c.x));
|
||||
|
||||
group
|
||||
.append("line")
|
||||
.attr("class", "link descendant-link")
|
||||
.attr("x1", minX)
|
||||
.attr("y1", midY)
|
||||
.attr("x2", maxX)
|
||||
.attr("y2", midY);
|
||||
|
||||
for (const child of children) {
|
||||
group
|
||||
.append("line")
|
||||
.attr("class", "link descendant-link")
|
||||
.attr("x1", child.x)
|
||||
.attr("y1", midY)
|
||||
.attr("x2", child.x)
|
||||
.attr("y2", childrenY);
|
||||
}
|
||||
}
|
||||
}
|
||||
31
resources/js/modules/lib/tree/link-drawer.js
Normal file
31
resources/js/modules/lib/tree/link-drawer.js
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Link/connector drawing utilities.
|
||||
*
|
||||
* Uses elbow (right-angle) connectors for a clean genealogy look.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Draw an elbow link between source and target nodes.
|
||||
*
|
||||
* @param {d3.Selection} group - SVG group to append the path to
|
||||
* @param {object} source - Source node with x, y coordinates
|
||||
* @param {object} target - Target node with x, y coordinates
|
||||
* @param {string} cssClass - Additional CSS class for the link
|
||||
* @param {Configuration} config
|
||||
*/
|
||||
export function drawElbowLink(group, source, target, cssClass, config) {
|
||||
const halfHeight = config.cardHeight / 2;
|
||||
|
||||
// Midpoint Y between source and target
|
||||
const midY = (source.y + target.y) / 2;
|
||||
|
||||
const path = `M ${source.x} ${source.y + (target.y > source.y ? halfHeight : -halfHeight)}
|
||||
L ${source.x} ${midY}
|
||||
L ${target.x} ${midY}
|
||||
L ${target.x} ${target.y + (target.y > source.y ? -halfHeight : halfHeight)}`;
|
||||
|
||||
group
|
||||
.append("path")
|
||||
.attr("class", `link ${cssClass}`)
|
||||
.attr("d", path);
|
||||
}
|
||||
60
resources/js/modules/lib/tree/sibling-layout.js
Normal file
60
resources/js/modules/lib/tree/sibling-layout.js
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Sibling node layout and rendering.
|
||||
*
|
||||
* Siblings are placed at the same Y-level as the root, to the left.
|
||||
* Connected via T-junction from the parent link.
|
||||
*/
|
||||
import { renderPersonCard } from "../chart/box.js";
|
||||
|
||||
/**
|
||||
* Render sibling nodes and their connectors.
|
||||
*
|
||||
* @param {d3.Selection} canvas
|
||||
* @param {Array} siblings - Sibling position data from computeSiblingPositions
|
||||
* @param {Array} links - Sibling links
|
||||
* @param {Configuration} config
|
||||
* @param {Function} onNodeClick
|
||||
* @param {string} containerSelector
|
||||
*/
|
||||
export function renderSiblings(canvas, siblings, links, config, onNodeClick, containerSelector) {
|
||||
if (siblings.length === 0) return;
|
||||
|
||||
const siblingGroup = canvas.append("g").attr("class", "sibling-nodes");
|
||||
const linkGroup = canvas.append("g").attr("class", "sibling-links");
|
||||
|
||||
// Draw a horizontal rail connecting all siblings + root
|
||||
const minX = 0;
|
||||
const maxX = siblings[siblings.length - 1].x;
|
||||
const railY = -config.cardHeight / 2 - 15;
|
||||
|
||||
// Vertical connector from parent area to rail
|
||||
linkGroup
|
||||
.append("line")
|
||||
.attr("class", "link sibling-link")
|
||||
.attr("x1", 0)
|
||||
.attr("y1", -config.cardHeight / 2)
|
||||
.attr("x2", 0)
|
||||
.attr("y2", railY);
|
||||
|
||||
// Horizontal rail
|
||||
linkGroup
|
||||
.append("line")
|
||||
.attr("class", "link sibling-link")
|
||||
.attr("x1", minX)
|
||||
.attr("y1", railY)
|
||||
.attr("x2", maxX)
|
||||
.attr("y2", railY);
|
||||
|
||||
// Vertical drops from rail to each sibling
|
||||
for (const sibling of siblings) {
|
||||
linkGroup
|
||||
.append("line")
|
||||
.attr("class", "link sibling-link")
|
||||
.attr("x1", sibling.x)
|
||||
.attr("y1", railY)
|
||||
.attr("x2", sibling.x)
|
||||
.attr("y2", sibling.y - config.cardHeight / 2);
|
||||
|
||||
renderPersonCard(siblingGroup, sibling, config, onNodeClick, containerSelector);
|
||||
}
|
||||
}
|
||||
39
resources/js/modules/lib/tree/spouse-util.js
Normal file
39
resources/js/modules/lib/tree/spouse-util.js
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Shared spouse placement utilities.
|
||||
*/
|
||||
|
||||
export const SPOUSE_GAP = 10; // px between partner cards
|
||||
|
||||
/**
|
||||
* Compute the X offset for spouse placement.
|
||||
*
|
||||
* Alternates right/left with increasing distance:
|
||||
* family 0 → right (1 * offset)
|
||||
* family 1 → left (-1 * offset)
|
||||
* family 2 → right (2 * offset)
|
||||
* family 3 → left (-2 * offset)
|
||||
*
|
||||
* @param {number} index - Family index (0-based)
|
||||
* @param {number} cardWidth
|
||||
* @param {number} gap
|
||||
* @returns {number} X offset from person position
|
||||
*/
|
||||
export function computeSpouseOffset(index, cardWidth, gap) {
|
||||
const unit = cardWidth + gap;
|
||||
const distance = Math.floor(index / 2) + 1;
|
||||
const direction = index % 2 === 0 ? 1 : -1;
|
||||
return direction * distance * unit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the couple midpoint X offset for a given family index.
|
||||
* This is half the spouse offset (the center between person and spouse).
|
||||
*
|
||||
* @param {number} familyIndex
|
||||
* @param {number} cardWidth
|
||||
* @param {number} gap
|
||||
* @returns {number}
|
||||
*/
|
||||
export function coupleMidpointOffset(familyIndex, cardWidth, gap) {
|
||||
return computeSpouseOffset(familyIndex, cardWidth, gap) / 2;
|
||||
}
|
||||
29
resources/views/modules/full-diagram/chart.phtml
Normal file
29
resources/views/modules/full-diagram/chart.phtml
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Fisharebest\Webtrees\Individual;
|
||||
use Fisharebest\Webtrees\Module\ModuleChartInterface;
|
||||
use Fisharebest\Webtrees\Tree;
|
||||
|
||||
/**
|
||||
* @var ModuleChartInterface $module
|
||||
* @var Individual $individual
|
||||
* @var Tree $tree
|
||||
* @var string $tree_data
|
||||
* @var string $javascript_url
|
||||
* @var string $stylesheet_url
|
||||
*/
|
||||
?>
|
||||
|
||||
<link rel="stylesheet" href="<?= e($stylesheet_url) ?>">
|
||||
|
||||
<div id="full-diagram-container" class="full-diagram-container wt-chart" data-tree-name="<?= e($tree->name()) ?>" style="width:100%;height:700px;">
|
||||
<div class="full-diagram-chart"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.fullDiagramData = <?= $tree_data ?>;
|
||||
window.fullDiagramBaseUrl = <?= json_encode(route($module::ROUTE_NAME, ['tree' => $tree->name(), 'xref' => '__XREF__']), JSON_THROW_ON_ERROR) ?>;
|
||||
</script>
|
||||
<script src="<?= e($javascript_url) ?>"></script>
|
||||
107
resources/views/modules/full-diagram/page.phtml
Normal file
107
resources/views/modules/full-diagram/page.phtml
Normal file
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Fisharebest\Webtrees\Http\RequestHandlers\IndividualPage;
|
||||
use Fisharebest\Webtrees\I18N;
|
||||
use Fisharebest\Webtrees\Individual;
|
||||
use Fisharebest\Webtrees\Module\ModuleChartInterface;
|
||||
use Fisharebest\Webtrees\Tree;
|
||||
use FullDiagram\Configuration;
|
||||
|
||||
/**
|
||||
* @var string $title
|
||||
* @var Individual $individual
|
||||
* @var ModuleChartInterface $module
|
||||
* @var Tree $tree
|
||||
* @var Configuration $configuration
|
||||
* @var string $tree_data
|
||||
* @var string $javascript_url
|
||||
* @var string $stylesheet_url
|
||||
* @var int $ancestor_generations
|
||||
* @var int $descendant_generations
|
||||
* @var bool $show_siblings
|
||||
* @var int $max_generations
|
||||
* @var int $min_generations
|
||||
*/
|
||||
?>
|
||||
|
||||
<?= view('components/breadcrumbs', [
|
||||
'links' => [
|
||||
route(IndividualPage::class, ['tree' => $tree->name(), 'xref' => $individual->xref()]) => $individual->fullName(),
|
||||
$title,
|
||||
],
|
||||
]) ?>
|
||||
|
||||
<h2 class="wt-page-title"><?= $title ?></h2>
|
||||
|
||||
<form method="post" class="wt-page-options wt-page-options-chart d-print-none mb-3">
|
||||
<?= csrf_field() ?>
|
||||
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-auto">
|
||||
<label for="ancestor_generations" class="form-label">
|
||||
<?= I18N::translate('Ancestor generations') ?>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
class="form-range"
|
||||
id="ancestor_generations"
|
||||
name="ancestor_generations"
|
||||
min="<?= $min_generations ?>"
|
||||
max="<?= $max_generations ?>"
|
||||
value="<?= $ancestor_generations ?>"
|
||||
oninput="document.getElementById('ancestor_gen_label').textContent = this.value"
|
||||
>
|
||||
<span id="ancestor_gen_label" class="badge bg-secondary"><?= $ancestor_generations ?></span>
|
||||
</div>
|
||||
|
||||
<div class="col-auto">
|
||||
<label for="descendant_generations" class="form-label">
|
||||
<?= I18N::translate('Descendant generations') ?>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
class="form-range"
|
||||
id="descendant_generations"
|
||||
name="descendant_generations"
|
||||
min="<?= $min_generations ?>"
|
||||
max="<?= $max_generations ?>"
|
||||
value="<?= $descendant_generations ?>"
|
||||
oninput="document.getElementById('descendant_gen_label').textContent = this.value"
|
||||
>
|
||||
<span id="descendant_gen_label" class="badge bg-secondary"><?= $descendant_generations ?></span>
|
||||
</div>
|
||||
|
||||
<div class="col-auto">
|
||||
<div class="form-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="form-check-input"
|
||||
id="show_siblings"
|
||||
name="show_siblings"
|
||||
value="1"
|
||||
<?= $show_siblings ? 'checked' : '' ?>
|
||||
>
|
||||
<label class="form-check-label" for="show_siblings">
|
||||
<?= I18N::translate('Show siblings') ?>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<?= I18N::translate('View') ?>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<?= view($module->name() . '::modules/full-diagram/chart', [
|
||||
'module' => $module,
|
||||
'individual' => $individual,
|
||||
'tree' => $tree,
|
||||
'tree_data' => $tree_data,
|
||||
'javascript_url' => $javascript_url,
|
||||
'stylesheet_url' => $stylesheet_url,
|
||||
]) ?>
|
||||
14
rollup.config.js
Normal file
14
rollup.config.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import resolve from "@rollup/plugin-node-resolve";
|
||||
import commonjs from "@rollup/plugin-commonjs";
|
||||
import terser from "@rollup/plugin-terser";
|
||||
|
||||
export default {
|
||||
input: "resources/js/modules/index.js",
|
||||
output: {
|
||||
file: "resources/js/full-diagram.min.js",
|
||||
format: "iife",
|
||||
name: "FullDiagram",
|
||||
sourcemap: false,
|
||||
},
|
||||
plugins: [resolve(), commonjs(), terser()],
|
||||
};
|
||||
30
src/Configuration.php
Normal file
30
src/Configuration.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace FullDiagram;
|
||||
|
||||
class Configuration
|
||||
{
|
||||
public function __construct(
|
||||
private readonly int $ancestorGenerations = 3,
|
||||
private readonly int $descendantGenerations = 3,
|
||||
private readonly bool $showSiblings = true,
|
||||
) {
|
||||
}
|
||||
|
||||
public function ancestorGenerations(): int
|
||||
{
|
||||
return $this->ancestorGenerations;
|
||||
}
|
||||
|
||||
public function descendantGenerations(): int
|
||||
{
|
||||
return $this->descendantGenerations;
|
||||
}
|
||||
|
||||
public function showSiblings(): bool
|
||||
{
|
||||
return $this->showSiblings;
|
||||
}
|
||||
}
|
||||
252
src/Facade/DataFacade.php
Normal file
252
src/Facade/DataFacade.php
Normal file
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace FullDiagram\Facade;
|
||||
|
||||
use Fisharebest\Webtrees\Individual;
|
||||
use FullDiagram\Configuration;
|
||||
|
||||
/**
|
||||
* Builds a flat person array suitable for the family-chart library.
|
||||
*
|
||||
* Phase 1: Traverse the tree (ancestors + descendants) to collect individuals.
|
||||
* Phase 2: Build relationship data for each collected individual, filtering
|
||||
* to only include relationships with other collected individuals.
|
||||
*
|
||||
* Output format per person (matches family-chart's expected structure):
|
||||
* { id, data: { gender, "first name", "last name", ... }, rels: { parents, spouses, children } }
|
||||
*/
|
||||
class DataFacade
|
||||
{
|
||||
/** @var array<string, Individual> Collected individuals keyed by xref */
|
||||
private array $individuals = [];
|
||||
|
||||
public function buildFullTree(Individual $root, Configuration $configuration): array
|
||||
{
|
||||
$this->individuals = [];
|
||||
|
||||
// Phase 1: Collect all individuals within configured depth
|
||||
$this->collectPerson($root);
|
||||
$this->collectAncestors($root, $configuration->ancestorGenerations(), $configuration->showSiblings());
|
||||
$this->collectDescendants($root, $configuration->descendantGenerations());
|
||||
|
||||
// Phase 2: Build flat person array with bidirectional relationships
|
||||
$persons = [];
|
||||
foreach ($this->individuals as $individual) {
|
||||
$persons[] = $this->buildPersonData($individual);
|
||||
}
|
||||
|
||||
return [
|
||||
'persons' => $persons,
|
||||
'mainId' => $root->xref(),
|
||||
];
|
||||
}
|
||||
|
||||
private function collectPerson(Individual $individual): void
|
||||
{
|
||||
$this->individuals[$individual->xref()] = $individual;
|
||||
}
|
||||
|
||||
private function collectAncestors(Individual $individual, int $generations, bool $showSiblings): void
|
||||
{
|
||||
if ($generations <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($individual->childFamilies() as $family) {
|
||||
$husband = $family->husband();
|
||||
$wife = $family->wife();
|
||||
|
||||
if ($husband !== null && !isset($this->individuals[$husband->xref()])) {
|
||||
$this->collectPerson($husband);
|
||||
$this->collectAncestors($husband, $generations - 1, $showSiblings);
|
||||
}
|
||||
|
||||
if ($wife !== null && !isset($this->individuals[$wife->xref()])) {
|
||||
$this->collectPerson($wife);
|
||||
$this->collectAncestors($wife, $generations - 1, $showSiblings);
|
||||
}
|
||||
|
||||
// Collect siblings (other children of this family)
|
||||
if ($showSiblings) {
|
||||
foreach ($family->children() as $child) {
|
||||
if (!isset($this->individuals[$child->xref()])) {
|
||||
$this->collectPerson($child);
|
||||
// One generation of descendants for siblings
|
||||
$this->collectDescendants($child, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function collectDescendants(Individual $individual, int $generations): void
|
||||
{
|
||||
if ($generations <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($individual->spouseFamilies() as $family) {
|
||||
$spouse = $family->spouse($individual);
|
||||
if ($spouse !== null && !isset($this->individuals[$spouse->xref()])) {
|
||||
$this->collectPerson($spouse);
|
||||
}
|
||||
|
||||
foreach ($family->children() as $child) {
|
||||
if (!isset($this->individuals[$child->xref()])) {
|
||||
$this->collectPerson($child);
|
||||
$this->collectDescendants($child, $generations - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a single person entry in family-chart format.
|
||||
*
|
||||
* Relationships are filtered to only include collected individuals,
|
||||
* ensuring the graph is self-consistent.
|
||||
*/
|
||||
private function buildPersonData(Individual $individual): array
|
||||
{
|
||||
$xref = $individual->xref();
|
||||
|
||||
// Relationships — only to other collected individuals
|
||||
$parents = [];
|
||||
$spouses = [];
|
||||
$children = [];
|
||||
|
||||
// Parents: from childFamilies
|
||||
foreach ($individual->childFamilies() as $family) {
|
||||
$husband = $family->husband();
|
||||
$wife = $family->wife();
|
||||
|
||||
if ($husband !== null && isset($this->individuals[$husband->xref()])) {
|
||||
$parents[] = $husband->xref();
|
||||
}
|
||||
if ($wife !== null && isset($this->individuals[$wife->xref()])) {
|
||||
$parents[] = $wife->xref();
|
||||
}
|
||||
}
|
||||
|
||||
// Spouses and children: from spouseFamilies
|
||||
foreach ($individual->spouseFamilies() as $family) {
|
||||
$spouse = $family->spouse($individual);
|
||||
if ($spouse !== null && isset($this->individuals[$spouse->xref()])) {
|
||||
$spouses[] = $spouse->xref();
|
||||
}
|
||||
|
||||
foreach ($family->children() as $child) {
|
||||
if (isset($this->individuals[$child->xref()])) {
|
||||
$children[] = $child->xref();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract personal data
|
||||
$names = $individual->getAllNames();
|
||||
$primaryName = $names[0] ?? [];
|
||||
$firstName = self::cleanGedcomName(trim($primaryName['givn'] ?? ''));
|
||||
$lastName = self::cleanGedcomName(trim($primaryName['surn'] ?? ''));
|
||||
|
||||
$thumbnailUrl = '';
|
||||
$media = $individual->findHighlightedMediaFile();
|
||||
if ($media !== null) {
|
||||
$thumbnailUrl = $media->imageUrl(80, 80, 'crop');
|
||||
}
|
||||
|
||||
// Marriage date from first spouse family
|
||||
$marriageDate = '';
|
||||
$spouseFamily = $individual->spouseFamilies()->first();
|
||||
if ($spouseFamily !== null) {
|
||||
$marriageFact = $spouseFamily->facts(['MARR'])->first();
|
||||
if ($marriageFact !== null && $marriageFact->date()->isOK()) {
|
||||
$marriageDate = strip_tags($marriageFact->date()->display());
|
||||
}
|
||||
}
|
||||
|
||||
// Check for ancestors/descendants beyond the current view
|
||||
$hasMoreAncestors = false;
|
||||
foreach ($individual->childFamilies() as $family) {
|
||||
if (($family->husband() !== null && !isset($this->individuals[$family->husband()->xref()])) ||
|
||||
($family->wife() !== null && !isset($this->individuals[$family->wife()->xref()]))) {
|
||||
$hasMoreAncestors = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $xref,
|
||||
'data' => [
|
||||
'gender' => $individual->sex() === 'M' ? 'M' : 'F',
|
||||
'first name' => $firstName,
|
||||
'last name' => $lastName,
|
||||
'fullName' => str_replace('@N.N.', "\u{2026}", strip_tags($individual->fullName())),
|
||||
'birthDate' => self::extractDate($individual, 'BIRT'),
|
||||
'birthYear' => self::extractYear($individual, 'BIRT'),
|
||||
'birthPlace' => self::extractPlace($individual, 'BIRT'),
|
||||
'deathDate' => self::extractDate($individual, 'DEAT'),
|
||||
'deathYear' => self::extractYear($individual, 'DEAT'),
|
||||
'deathPlace' => self::extractPlace($individual, 'DEAT'),
|
||||
'baptismDate' => self::extractDate($individual, 'BAPM') ?: self::extractDate($individual, 'CHR'),
|
||||
'marriageDate' => $marriageDate,
|
||||
'occupation' => self::extractFactValue($individual, 'OCCU'),
|
||||
'residence' => self::extractFactValue($individual, 'RESI'),
|
||||
'isDead' => $individual->isDead(),
|
||||
'avatar' => $thumbnailUrl,
|
||||
'url' => $individual->url(),
|
||||
'hasMoreAncestors' => $hasMoreAncestors,
|
||||
],
|
||||
'rels' => [
|
||||
'parents' => array_values(array_unique($parents)),
|
||||
'spouses' => array_values(array_unique($spouses)),
|
||||
'children' => array_values(array_unique($children)),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private static function cleanGedcomName(string $name): string
|
||||
{
|
||||
if (preg_match('/^@[A-Z]\.N\.$/', $name)) {
|
||||
return '';
|
||||
}
|
||||
return $name;
|
||||
}
|
||||
|
||||
private static function extractDate(Individual $individual, string $tag): string
|
||||
{
|
||||
$fact = $individual->facts([$tag])->first();
|
||||
if ($fact === null || !$fact->date()->isOK()) {
|
||||
return '';
|
||||
}
|
||||
return strip_tags($fact->date()->display());
|
||||
}
|
||||
|
||||
private static function extractYear(Individual $individual, string $tag): string
|
||||
{
|
||||
$fact = $individual->facts([$tag])->first();
|
||||
if ($fact === null || !$fact->date()->isOK()) {
|
||||
return '';
|
||||
}
|
||||
return (string) $fact->date()->minimumDate()->year();
|
||||
}
|
||||
|
||||
private static function extractPlace(Individual $individual, string $tag): string
|
||||
{
|
||||
$fact = $individual->facts([$tag])->first();
|
||||
if ($fact === null) {
|
||||
return '';
|
||||
}
|
||||
return $fact->place()->gedcomName();
|
||||
}
|
||||
|
||||
private static function extractFactValue(Individual $individual, string $tag): string
|
||||
{
|
||||
$fact = $individual->facts([$tag])->first();
|
||||
if ($fact === null) {
|
||||
return '';
|
||||
}
|
||||
return trim($fact->value());
|
||||
}
|
||||
}
|
||||
39
src/Model/FamilyNode.php
Normal file
39
src/Model/FamilyNode.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace FullDiagram\Model;
|
||||
|
||||
use JsonSerializable;
|
||||
|
||||
class FamilyNode implements JsonSerializable
|
||||
{
|
||||
/**
|
||||
* @param NodeData|null $spouse
|
||||
* @param list<NodeData> $children
|
||||
* @param string $familyXref
|
||||
* @param list<NodeData> $parents Used in ancestor context (both parents)
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly ?NodeData $spouse,
|
||||
private readonly array $children = [],
|
||||
private readonly string $familyXref = '',
|
||||
private readonly array $parents = [],
|
||||
) {
|
||||
}
|
||||
|
||||
public function jsonSerialize(): mixed
|
||||
{
|
||||
$data = [
|
||||
'familyXref' => $this->familyXref,
|
||||
'spouse' => $this->spouse,
|
||||
'children' => $this->children,
|
||||
];
|
||||
|
||||
if ($this->parents !== []) {
|
||||
$data['parents'] = $this->parents;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
228
src/Model/NodeData.php
Normal file
228
src/Model/NodeData.php
Normal file
@@ -0,0 +1,228 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace FullDiagram\Model;
|
||||
|
||||
use Fisharebest\Webtrees\Individual;
|
||||
use JsonSerializable;
|
||||
|
||||
class NodeData implements JsonSerializable
|
||||
{
|
||||
private string $xref;
|
||||
private string $firstName;
|
||||
private string $lastName;
|
||||
private string $fullName;
|
||||
private string $sex;
|
||||
private string $birthDate;
|
||||
private string $birthYear;
|
||||
private string $birthPlace;
|
||||
private string $deathDate;
|
||||
private string $deathYear;
|
||||
private string $deathPlace;
|
||||
private string $baptismDate;
|
||||
private string $marriageDate;
|
||||
private string $occupation;
|
||||
private string $residence;
|
||||
private bool $isDead;
|
||||
private bool $hasMoreAncestors = false;
|
||||
private bool $hasMoreDescendants = false;
|
||||
private string $thumbnailUrl;
|
||||
private string $url;
|
||||
private bool $isSibling;
|
||||
private bool $isRoot;
|
||||
|
||||
/** @var list<FamilyNode> Parent families (ancestor direction) */
|
||||
private array $parentFamilies = [];
|
||||
|
||||
/** @var list<FamilyNode> Spouse families (descendant direction) */
|
||||
private array $families = [];
|
||||
|
||||
/** @param list<FamilyNode> $parentFamilies */
|
||||
public function setParentFamilies(array $parentFamilies): void
|
||||
{
|
||||
$this->parentFamilies = $parentFamilies;
|
||||
}
|
||||
|
||||
private function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
public static function fromIndividual(Individual $individual, bool $isSibling = false, bool $isRoot = false): self
|
||||
{
|
||||
$node = new self();
|
||||
|
||||
$node->xref = $individual->xref();
|
||||
$node->fullName = str_replace('@N.N.', "\u{2026}", strip_tags($individual->fullName()));
|
||||
$node->sex = $individual->sex();
|
||||
$node->isDead = $individual->isDead();
|
||||
$node->thumbnailUrl = self::extractThumbnail($individual);
|
||||
$node->url = $individual->url();
|
||||
$node->isSibling = $isSibling;
|
||||
$node->isRoot = $isRoot;
|
||||
|
||||
// Parse first/last name from GEDCOM name parts
|
||||
// Filter out GEDCOM unknown-name placeholders like @N.N., @P.N.
|
||||
$names = $individual->getAllNames();
|
||||
$primaryName = $names[0] ?? [];
|
||||
$node->firstName = self::cleanGedcomName(trim($primaryName['givn'] ?? ''));
|
||||
$node->lastName = self::cleanGedcomName(trim($primaryName['surn'] ?? ''));
|
||||
|
||||
// Dates and places
|
||||
$node->birthDate = self::extractDate($individual, 'BIRT');
|
||||
$node->birthYear = self::extractYear($individual, 'BIRT');
|
||||
$node->birthPlace = self::extractPlace($individual, 'BIRT');
|
||||
$node->deathDate = self::extractDate($individual, 'DEAT');
|
||||
$node->deathYear = self::extractYear($individual, 'DEAT');
|
||||
$node->deathPlace = self::extractPlace($individual, 'DEAT');
|
||||
$node->baptismDate = self::extractDate($individual, 'BAPM')
|
||||
?: self::extractDate($individual, 'CHR');
|
||||
$node->occupation = self::extractFactValue($individual, 'OCCU');
|
||||
$node->residence = self::extractFactValue($individual, 'RESI');
|
||||
|
||||
// Marriage date from first spouse family
|
||||
$node->marriageDate = '';
|
||||
$spouseFamily = $individual->spouseFamilies()->first();
|
||||
if ($spouseFamily !== null) {
|
||||
$marriageFact = $spouseFamily->facts(['MARR'])->first();
|
||||
if ($marriageFact !== null && $marriageFact->date()->isOK()) {
|
||||
$node->marriageDate = strip_tags($marriageFact->date()->display());
|
||||
}
|
||||
}
|
||||
|
||||
return $node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace GEDCOM unknown-name placeholders (@N.N., @P.N.) with empty string.
|
||||
*/
|
||||
private static function cleanGedcomName(string $name): string
|
||||
{
|
||||
// @N.N. = nomen nescio (unknown surname), @P.N. = praenomen nescio (unknown given name)
|
||||
if (preg_match('/^@[A-Z]\.N\.$/', $name)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $name;
|
||||
}
|
||||
|
||||
private static function extractDate(Individual $individual, string $tag): string
|
||||
{
|
||||
$fact = $individual->facts([$tag])->first();
|
||||
if ($fact === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$date = $fact->date();
|
||||
if (!$date->isOK()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return strip_tags($date->display());
|
||||
}
|
||||
|
||||
private static function extractYear(Individual $individual, string $tag): string
|
||||
{
|
||||
$fact = $individual->facts([$tag])->first();
|
||||
if ($fact === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$date = $fact->date();
|
||||
if (!$date->isOK()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return (string) $date->minimumDate()->year();
|
||||
}
|
||||
|
||||
private static function extractPlace(Individual $individual, string $tag): string
|
||||
{
|
||||
$fact = $individual->facts([$tag])->first();
|
||||
if ($fact === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$place = $fact->place();
|
||||
return $place->gedcomName();
|
||||
}
|
||||
|
||||
private static function extractFactValue(Individual $individual, string $tag): string
|
||||
{
|
||||
$fact = $individual->facts([$tag])->first();
|
||||
if ($fact === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return trim($fact->value());
|
||||
}
|
||||
|
||||
private static function extractThumbnail(Individual $individual): string
|
||||
{
|
||||
$media = $individual->findHighlightedMediaFile();
|
||||
if ($media === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $media->imageUrl(80, 80, 'crop');
|
||||
}
|
||||
|
||||
public function xref(): string
|
||||
{
|
||||
return $this->xref;
|
||||
}
|
||||
|
||||
/** @param list<FamilyNode> $families */
|
||||
public function setFamilies(array $families): void
|
||||
{
|
||||
$this->families = $families;
|
||||
}
|
||||
|
||||
public function setHasMoreAncestors(bool $value): void
|
||||
{
|
||||
$this->hasMoreAncestors = $value;
|
||||
}
|
||||
|
||||
public function setHasMoreDescendants(bool $value): void
|
||||
{
|
||||
$this->hasMoreDescendants = $value;
|
||||
}
|
||||
|
||||
public function jsonSerialize(): mixed
|
||||
{
|
||||
$data = [
|
||||
'xref' => $this->xref,
|
||||
'firstName' => $this->firstName,
|
||||
'lastName' => $this->lastName,
|
||||
'fullName' => $this->fullName,
|
||||
'sex' => $this->sex,
|
||||
'birthDate' => $this->birthDate,
|
||||
'birthYear' => $this->birthYear,
|
||||
'birthPlace' => $this->birthPlace,
|
||||
'deathDate' => $this->deathDate,
|
||||
'deathYear' => $this->deathYear,
|
||||
'deathPlace' => $this->deathPlace,
|
||||
'baptismDate' => $this->baptismDate,
|
||||
'marriageDate' => $this->marriageDate,
|
||||
'occupation' => $this->occupation,
|
||||
'residence' => $this->residence,
|
||||
'isDead' => $this->isDead,
|
||||
'thumbnailUrl' => $this->thumbnailUrl,
|
||||
'url' => $this->url,
|
||||
'isSibling' => $this->isSibling,
|
||||
'isRoot' => $this->isRoot,
|
||||
'hasMoreAncestors' => $this->hasMoreAncestors,
|
||||
'hasMoreDescendants'=> $this->hasMoreDescendants,
|
||||
];
|
||||
|
||||
if ($this->parentFamilies !== []) {
|
||||
$data['parentFamilies'] = $this->parentFamilies;
|
||||
}
|
||||
|
||||
if ($this->families !== []) {
|
||||
$data['families'] = $this->families;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
169
src/Module.php
Normal file
169
src/Module.php
Normal file
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Full Diagram module for webtrees.
|
||||
*
|
||||
* @license GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace FullDiagram;
|
||||
|
||||
use Fig\Http\Message\RequestMethodInterface;
|
||||
use Fisharebest\Webtrees\Auth;
|
||||
use Fisharebest\Webtrees\I18N;
|
||||
use Fisharebest\Webtrees\Individual;
|
||||
use Fisharebest\Webtrees\Menu;
|
||||
use Fisharebest\Webtrees\Module\AbstractModule;
|
||||
use Fisharebest\Webtrees\Module\ModuleChartInterface;
|
||||
use Fisharebest\Webtrees\Module\ModuleChartTrait;
|
||||
use Fisharebest\Webtrees\Module\ModuleCustomInterface;
|
||||
use Fisharebest\Webtrees\Module\ModuleCustomTrait;
|
||||
use Fisharebest\Webtrees\Registry;
|
||||
use Fisharebest\Webtrees\Validator;
|
||||
use Fisharebest\Webtrees\View;
|
||||
use FullDiagram\Facade\DataFacade;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
|
||||
class Module extends AbstractModule implements ModuleChartInterface, ModuleCustomInterface, RequestHandlerInterface
|
||||
{
|
||||
use ModuleChartTrait;
|
||||
use ModuleCustomTrait;
|
||||
|
||||
public const ROUTE_NAME = 'full-diagram';
|
||||
public const ROUTE_URL = '/tree/{tree}/full-diagram/{xref}';
|
||||
|
||||
private const DEFAULT_ANCESTOR_GENERATIONS = 3;
|
||||
private const DEFAULT_DESCENDANT_GENERATIONS = 3;
|
||||
private const MINIMUM_GENERATIONS = 1;
|
||||
private const MAXIMUM_GENERATIONS = 10;
|
||||
|
||||
public function title(): string
|
||||
{
|
||||
return I18N::translate('Full Diagram');
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return I18N::translate('An interactive diagram showing ancestors, descendants, and siblings.');
|
||||
}
|
||||
|
||||
public function customModuleAuthorName(): string
|
||||
{
|
||||
return 'Alex';
|
||||
}
|
||||
|
||||
public function customModuleVersion(): string
|
||||
{
|
||||
return '0.1.0';
|
||||
}
|
||||
|
||||
public function customModuleSupportUrl(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function resourcesFolder(): string
|
||||
{
|
||||
return __DIR__ . '/../resources/';
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
View::registerNamespace($this->name(), $this->resourcesFolder() . 'views/');
|
||||
|
||||
Registry::routeFactory()->routeMap()
|
||||
->get(self::ROUTE_NAME, self::ROUTE_URL, $this)
|
||||
->allows(RequestMethodInterface::METHOD_POST);
|
||||
}
|
||||
|
||||
public function chartMenuClass(): string
|
||||
{
|
||||
return 'menu-chart-full-diagram';
|
||||
}
|
||||
|
||||
public function chartBoxMenu(Individual $individual): Menu|null
|
||||
{
|
||||
return $this->chartMenu($individual);
|
||||
}
|
||||
|
||||
public function chartUrl(Individual $individual, array $parameters = []): string
|
||||
{
|
||||
return route(self::ROUTE_NAME, [
|
||||
'tree' => $individual->tree()->name(),
|
||||
'xref' => $individual->xref(),
|
||||
] + $parameters);
|
||||
}
|
||||
|
||||
public function chartTitle(Individual $individual): string
|
||||
{
|
||||
return I18N::translate('Full Diagram of %s', $individual->fullName());
|
||||
}
|
||||
|
||||
public function handle(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
$tree = Validator::attributes($request)->tree();
|
||||
$xref = Validator::attributes($request)->isXref()->string('xref');
|
||||
$individual = Registry::individualFactory()->make($xref, $tree);
|
||||
$individual = Auth::checkIndividualAccess($individual, false, true);
|
||||
|
||||
// Redirect POST to GET for clean URLs
|
||||
if ($request->getMethod() === RequestMethodInterface::METHOD_POST) {
|
||||
$params = (array) $request->getParsedBody();
|
||||
|
||||
return redirect($this->chartUrl($individual, [
|
||||
'ancestor_generations' => $params['ancestor_generations'] ?? self::DEFAULT_ANCESTOR_GENERATIONS,
|
||||
'descendant_generations' => $params['descendant_generations'] ?? self::DEFAULT_DESCENDANT_GENERATIONS,
|
||||
'show_siblings' => $params['show_siblings'] ?? '1',
|
||||
]));
|
||||
}
|
||||
|
||||
$ancestorGenerations = Validator::queryParams($request)
|
||||
->isBetween(self::MINIMUM_GENERATIONS, self::MAXIMUM_GENERATIONS)
|
||||
->integer('ancestor_generations', self::DEFAULT_ANCESTOR_GENERATIONS);
|
||||
|
||||
$descendantGenerations = Validator::queryParams($request)
|
||||
->isBetween(self::MINIMUM_GENERATIONS, self::MAXIMUM_GENERATIONS)
|
||||
->integer('descendant_generations', self::DEFAULT_DESCENDANT_GENERATIONS);
|
||||
|
||||
$showSiblings = Validator::queryParams($request)
|
||||
->string('show_siblings', '1') === '1';
|
||||
|
||||
// Check for AJAX request
|
||||
$ajax = Validator::queryParams($request)->string('ajax', '') === '1';
|
||||
|
||||
$configuration = new Configuration(
|
||||
$ancestorGenerations,
|
||||
$descendantGenerations,
|
||||
$showSiblings,
|
||||
);
|
||||
|
||||
$dataFacade = new DataFacade();
|
||||
$treeData = $dataFacade->buildFullTree($individual, $configuration);
|
||||
|
||||
if ($ajax) {
|
||||
return response([
|
||||
'data' => $treeData,
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->viewResponse($this->name() . '::modules/full-diagram/page', [
|
||||
'title' => $this->chartTitle($individual),
|
||||
'individual' => $individual,
|
||||
'module' => $this,
|
||||
'tree' => $tree,
|
||||
'configuration' => $configuration,
|
||||
'tree_data' => json_encode($treeData, JSON_THROW_ON_ERROR),
|
||||
'javascript_url' => $this->assetUrl('js/full-diagram.min.js'),
|
||||
'stylesheet_url' => $this->assetUrl('css/full-diagram.css'),
|
||||
'ancestor_generations' => $ancestorGenerations,
|
||||
'descendant_generations' => $descendantGenerations,
|
||||
'show_siblings' => $showSiblings,
|
||||
'max_generations' => self::MAXIMUM_GENERATIONS,
|
||||
'min_generations' => self::MINIMUM_GENERATIONS,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user