diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1affc5f..3598f91 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,97 +2,72 @@ name: CI on: push: - branches: [ main, master ] + branches: ["**"] pull_request: - branches: [ main, master ] + branches: ["**"] + +permissions: + contents: read jobs: - build: - name: Build & Test + build-test-wasm: runs-on: ubuntu-latest - strategy: - matrix: - go-version: [ '1.22.x', '1.23.x' ] + steps: - name: Checkout uses: actions/checkout@v4 - - name: Setup Go ${{ matrix.go-version }} + - name: Setup Go uses: actions/setup-go@v5 with: - go-version: ${{ matrix.go-version }} - cache: true + go-version: '1.23.x' + + - name: Install extra tools + run: | + go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest + go install golang.org/x/vuln/cmd/govulncheck@latest + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' - name: Go env run: go env - - name: Tidy check + - name: CI checks (lint, tests, coverage, etc.) + run: make ci + + - name: Build WebAssembly module + run: make wasm-build + + - name: Prepare npm package folder + run: make npm-pack + + - name: Create npm tarball + id: npm_pack run: | - go mod tidy - git diff --exit-code -- go.mod go.sum + echo "tarball=$(npm pack ./npm/poindexter-wasm)" >> "$GITHUB_OUTPUT" - - name: Build - run: go build ./... - - - name: Vet - run: go vet ./... - - - name: Test (race + coverage) - run: go test -race -coverprofile=coverage.out -covermode=atomic -coverpkg=./... ./... - - - name: Fuzz (10s) - run: | - set -e - for pkg in $(go list ./...); do - FUZZES=$(go test -list '^Fuzz' "$pkg" | grep '^Fuzz' || true) - if [ -z "$FUZZES" ]; then - echo "==> Skipping $pkg (no fuzz targets)" - continue - fi - for fz in $FUZZES; do - echo "==> Fuzzing $pkg :: $fz for 10s" - go test -run=NONE -fuzz="^$fz$" -fuzztime=10s "$pkg" - done - done - - - name: Upload coverage artifact - if: always() + - name: Upload dist (WASM artifacts) uses: actions/upload-artifact@v4 with: - name: coverage-${{ matrix.go-version }} - path: coverage.out + name: poindexter-wasm-dist + if-no-files-found: error + path: | + dist/poindexter.wasm + dist/wasm_exec.js - - name: Upload to Codecov - uses: codecov/codecov-action@v4 - with: - files: coverage.out - flags: unit - fail_ci_if_error: false - - - name: Build examples - run: | - if [ -d examples ]; then - go build ./examples/... - fi - - - name: Benchmarks (benchmem) - run: | - go test -bench . -benchmem -run=^$ ./... | tee bench-${{ matrix.go-version }}.txt - - - name: Upload benchmark artifact - if: always() + - name: Upload npm package folder uses: actions/upload-artifact@v4 with: - name: bench-${{ matrix.go-version }} - path: bench-${{ matrix.go-version }}.txt + name: npm-poindexter-wasm + if-no-files-found: error + path: npm/poindexter-wasm/** - - name: Vulncheck - uses: golang/govulncheck-action@v1 + - name: Upload npm tarball + uses: actions/upload-artifact@v4 with: - go-version-input: ${{ matrix.go-version }} - - - name: Lint - uses: golangci/golangci-lint-action@v6 - with: - version: latest - args: --timeout=5m + name: npm-poindexter-wasm-tarball + if-no-files-found: error + path: ${{ steps.npm_pack.outputs.tarball }} diff --git a/Makefile b/Makefile index ce5c962..590912a 100644 --- a/Makefile +++ b/Makefile @@ -40,6 +40,36 @@ vet: ## Run go vet build: ## Build all packages $(GO) build ./... +# WebAssembly build outputs +DIST_DIR ?= dist +WASM_OUT ?= $(DIST_DIR)/poindexter.wasm +WASM_EXEC ?= $(shell $(GO) env GOROOT)/lib/wasm/wasm_exec.js + +.PHONY: wasm-build +wasm-build: ## Build WebAssembly module to $(WASM_OUT) + @mkdir -p $(DIST_DIR) + GOOS=js GOARCH=wasm $(GO) build -o $(WASM_OUT) ./wasm + @set -e; \ + if [ -n "$$WASM_EXEC" ] && [ -f "$$WASM_EXEC" ]; then \ + cp "$$WASM_EXEC" $(DIST_DIR)/wasm_exec.js; \ + else \ + CAND1="$$($(GO) env GOROOT)/lib/wasm/wasm_exec.js"; \ + CAND2="$$($(GO) env GOROOT)/libexec/lib/wasm/wasm_exec.js"; \ + if [ -f "$$CAND1" ]; then cp "$$CAND1" $(DIST_DIR)/wasm_exec.js; \ + elif [ -f "$$CAND2" ]; then cp "$$CAND2" $(DIST_DIR)/wasm_exec.js; \ + else echo "Warning: could not locate wasm_exec.js under GOROOT or WASM_EXEC; please copy it manually"; fi; \ + fi + @echo "WASM built: $(WASM_OUT)" + +.PHONY: npm-pack +npm-pack: wasm-build ## Prepare npm package folder with dist artifacts + @mkdir -p npm/poindexter-wasm + @rm -rf npm/poindexter-wasm/dist + @cp -R $(DIST_DIR) npm/poindexter-wasm/dist + @cp LICENSE npm/poindexter-wasm/LICENSE + @cp README.md npm/poindexter-wasm/PROJECT_README.md + @echo "npm package prepared in npm/poindexter-wasm" + .PHONY: examples examples: ## Build all example programs under examples/ @if [ -d examples ]; then $(GO) build ./examples/...; else echo "No examples/ directory"; fi @@ -99,7 +129,7 @@ vuln: ## Run govulncheck (requires it installed) govulncheck ./... .PHONY: ci -ci: tidy-check build vet cover examples bench lint vuln ## CI-parity local run +ci: tidy-check build vet cover examples bench lint vuln wasm-build ## CI-parity local run (includes wasm-build) @echo "CI-like checks completed" .PHONY: release diff --git a/docs/wasm.md b/docs/wasm.md new file mode 100644 index 0000000..373b793 --- /dev/null +++ b/docs/wasm.md @@ -0,0 +1,104 @@ +# Browser/WebAssembly (WASM) + +Poindexter ships a browser build compiled to WebAssembly along with a small JS loader and TypeScript types. This allows you to use the KD‑Tree functionality directly from web apps (Angular, React, Vue, plain ESM, etc.). + +## What’s included + +- `dist/poindexter.wasm` — the compiled Go WASM module +- `dist/wasm_exec.js` — Go’s runtime shim required to run WASM in the browser +- `npm/poindexter-wasm/loader.js` — ESM loader that instantiates the WASM and exposes a friendly API +- `npm/poindexter-wasm/index.d.ts` — TypeScript typings for the loader and KD‑Tree API + +## Building locally + +```bash +make wasm-build +``` + +This produces `dist/poindexter.wasm` and copies `wasm_exec.js` into `dist/` from your Go installation. If your environment is non‑standard, you can override the path: + +```bash +WASM_EXEC=/custom/path/wasm_exec.js make wasm-build +``` + +To assemble the npm package folder with the built artifacts: + +```bash +make npm-pack +``` + +This populates `npm/poindexter-wasm/` with `dist/`, license and readme files. You can then create a tarball for local testing: + +```bash +npm pack ./npm/poindexter-wasm +``` + +## Using in Angular (example) + +1) Install the package (use the tarball generated above or a published version): + +```bash +npm install /snider-poindexter-wasm-0.0.0-development.tgz +# or once published +npm install @snider/poindexter-wasm +``` + +2) Make the WASM runtime files available as app assets. In `angular.json` under `build.options.assets`: + +```json +{ + "glob": "**/*", + "input": "node_modules/@snider/poindexter-wasm/dist", + "output": "/assets/poindexter/" +} +``` + +3) Import and initialize in your code: + +```ts +import { init } from '@snider/poindexter-wasm'; + +const px = await init({ + // If you used the assets mapping above, these defaults should work: + wasmURL: '/assets/poindexter/poindexter.wasm', + wasmExecURL: '/assets/poindexter/wasm_exec.js', +}); + +const tree = await px.newTree(2); +await tree.insert({ id: 'a', coords: [0, 0], value: 'A' }); +const nearest = await tree.nearest([0.1, 0.2]); +console.log(nearest); +``` + +## JavaScript API + +Top‑level functions returned by `init()`: + +- `version(): string` +- `hello(name?: string): string` +- `newTree(dim: number): Promise` + +Tree methods: + +- `dim(): Promise` +- `len(): Promise` +- `insert(p: { id: string; coords: number[]; value?: string }): Promise` +- `deleteByID(id: string): Promise` +- `nearest(query: number[]): Promise<{ id: string; coords: number[]; value: string; dist: number } | null>` +- `kNearest(query: number[], k: number): Promise>` +- `radius(query: number[], r: number): Promise>` +- `exportJSON(): Promise` + +Notes: +- The WASM bridge currently uses `KDTree[string]` for values to keep the boundary simple. You can encode richer payloads as JSON strings if needed. +- `wasm_exec.js` must be available next to the `.wasm` file (the loader accepts explicit URLs if you place them elsewhere). + +## CI artifacts + +Our CI builds and uploads the following artifacts on each push/PR: + +- `poindexter-wasm-dist` — the `dist/` folder containing `poindexter.wasm` and `wasm_exec.js` +- `npm-poindexter-wasm` — the prepared npm package folder with `dist/` and documentation +- `npm-poindexter-wasm-tarball` — a `.tgz` created via `npm pack` for quick local install/testing + +You can download these artifacts from the workflow run summary in GitHub Actions. diff --git a/mkdocs.yml b/mkdocs.yml index 6c4c6a0..1d9f476 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -55,6 +55,7 @@ markdown_extensions: nav: - Home: index.md - Getting Started: getting-started.md + - WebAssembly (Browser): wasm.md - Examples: - Best Ping Peer (DHT): dht-best-ping.md - Multi-Dimensional KDTree (DHT): kdtree-multidimensional.md diff --git a/npm/poindexter-wasm/LICENSE b/npm/poindexter-wasm/LICENSE new file mode 100644 index 0000000..6d8cea4 --- /dev/null +++ b/npm/poindexter-wasm/LICENSE @@ -0,0 +1,190 @@ +EUROPEAN UNION PUBLIC LICENCE v. 1.2 +EUPL © the European Union 2007, 2016 + +This European Union Public Licence (the ‘EUPL’) applies to the Work (as defined below) which is provided under the +terms of this Licence. Any use of the Work, other than as authorised under this Licence is prohibited (to the extent such +use is covered by a right of the copyright holder of the Work). +The Work is provided under the terms of this Licence when the Licensor (as defined below) has placed the following +notice immediately following the copyright notice for the Work: + Licensed under the EUPL +or has expressed by any other means his willingness to license under the EUPL. + +1.Definitions +In this Licence, the following terms have the following meaning: +— ‘The Licence’:this Licence. +— ‘The Original Work’:the work or software distributed or communicated by the Licensor under this Licence, available +as Source Code and also as Executable Code as the case may be. +— ‘Derivative Works’:the works or software that could be created by the Licensee, based upon the Original Work or +modifications thereof. This Licence does not define the extent of modification or dependence on the Original Work +required in order to classify a work as a Derivative Work; this extent is determined by copyright law applicable in +the country mentioned in Article 15. +— ‘The Work’:the Original Work or its Derivative Works. +— ‘The Source Code’:the human-readable form of the Work which is the most convenient for people to study and +modify. +— ‘The Executable Code’:any code which has generally been compiled and which is meant to be interpreted by +a computer as a program. +— ‘The Licensor’:the natural or legal person that distributes or communicates the Work under the Licence. +— ‘Contributor(s)’:any natural or legal person who modifies the Work under the Licence, or otherwise contributes to +the creation of a Derivative Work. +— ‘The Licensee’ or ‘You’:any natural or legal person who makes any usage of the Work under the terms of the +Licence. +— ‘Distribution’ or ‘Communication’:any act of selling, giving, lending, renting, distributing, communicating, +transmitting, or otherwise making available, online or offline, copies of the Work or providing access to its essential +functionalities at the disposal of any other natural or legal person. + +2.Scope of the rights granted by the Licence +The Licensor hereby grants You a worldwide, royalty-free, non-exclusive, sublicensable licence to do the following, for +the duration of copyright vested in the Original Work: +— use the Work in any circumstance and for all usage, +— reproduce the Work, +— modify the Work, and make Derivative Works based upon the Work, +— communicate to the public, including the right to make available or display the Work or copies thereof to the public +and perform publicly, as the case may be, the Work, +— distribute the Work or copies thereof, +— lend and rent the Work or copies thereof, +— sublicense rights in the Work or copies thereof. +Those rights can be exercised on any media, supports and formats, whether now known or later invented, as far as the +applicable law permits so. +In the countries where moral rights apply, the Licensor waives his right to exercise his moral right to the extent allowed +by law in order to make effective the licence of the economic rights here above listed. +The Licensor grants to the Licensee royalty-free, non-exclusive usage rights to any patents held by the Licensor, to the +extent necessary to make use of the rights granted on the Work under this Licence. + +3.Communication of the Source Code +The Licensor may provide the Work either in its Source Code form, or as Executable Code. If the Work is provided as +Executable Code, the Licensor provides in addition a machine-readable copy of the Source Code of the Work along with +each copy of the Work that the Licensor distributes or indicates, in a notice following the copyright notice attached to +the Work, a repository where the Source Code is easily and freely accessible for as long as the Licensor continues to +distribute or communicate the Work. + +4.Limitations on copyright +Nothing in this Licence is intended to deprive the Licensee of the benefits from any exception or limitation to the +exclusive rights of the rights owners in the Work, of the exhaustion of those rights or of other applicable limitations +thereto. + +5.Obligations of the Licensee +The grant of the rights mentioned above is subject to some restrictions and obligations imposed on the Licensee. Those +obligations are the following: + +Attribution right: The Licensee shall keep intact all copyright, patent or trademarks notices and all notices that refer to +the Licence and to the disclaimer of warranties. The Licensee must include a copy of such notices and a copy of the +Licence with every copy of the Work he/she distributes or communicates. The Licensee must cause any Derivative Work +to carry prominent notices stating that the Work has been modified and the date of modification. + +Copyleft clause: If the Licensee distributes or communicates copies of the Original Works or Derivative Works, this +Distribution or Communication will be done under the terms of this Licence or of a later version of this Licence unless +the Original Work is expressly distributed only under this version of the Licence — for example by communicating +‘EUPL v. 1.2 only’. The Licensee (becoming Licensor) cannot offer or impose any additional terms or conditions on the +Work or Derivative Work that alter or restrict the terms of the Licence. + +Compatibility clause: If the Licensee Distributes or Communicates Derivative Works or copies thereof based upon both +the Work and another work licensed under a Compatible Licence, this Distribution or Communication can be done +under the terms of this Compatible Licence. For the sake of this clause, ‘Compatible Licence’ refers to the licences listed +in the appendix attached to this Licence. Should the Licensee's obligations under the Compatible Licence conflict with +his/her obligations under this Licence, the obligations of the Compatible Licence shall prevail. + +Provision of Source Code: When distributing or communicating copies of the Work, the Licensee will provide +a machine-readable copy of the Source Code or indicate a repository where this Source will be easily and freely available +for as long as the Licensee continues to distribute or communicate the Work. +Legal Protection: This Licence does not grant permission to use the trade names, trademarks, service marks, or names +of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and +reproducing the content of the copyright notice. + +6.Chain of Authorship +The original Licensor warrants that the copyright in the Original Work granted hereunder is owned by him/her or +licensed to him/her and that he/she has the power and authority to grant the Licence. +Each Contributor warrants that the copyright in the modifications he/she brings to the Work are owned by him/her or +licensed to him/her and that he/she has the power and authority to grant the Licence. +Each time You accept the Licence, the original Licensor and subsequent Contributors grant You a licence to their contributions +to the Work, under the terms of this Licence. + +7.Disclaimer of Warranty +The Work is a work in progress, which is continuously improved by numerous Contributors. It is not a finished work +and may therefore contain defects or ‘bugs’ inherent to this type of development. +For the above reason, the Work is provided under the Licence on an ‘as is’ basis and without warranties of any kind +concerning the Work, including without limitation merchantability, fitness for a particular purpose, absence of defects or +errors, accuracy, non-infringement of intellectual property rights other than copyright as stated in Article 6 of this +Licence. +This disclaimer of warranty is an essential part of the Licence and a condition for the grant of any rights to the Work. + +8.Disclaimer of Liability +Except in the cases of wilful misconduct or damages directly caused to natural persons, the Licensor will in no event be +liable for any direct or indirect, material or moral, damages of any kind, arising out of the Licence or of the use of the +Work, including without limitation, damages for loss of goodwill, work stoppage, computer failure or malfunction, loss +of data or any commercial damage, even if the Licensor has been advised of the possibility of such damage. However, +the Licensor will be liable under statutory product liability laws as far such laws apply to the Work. + +9.Additional agreements +While distributing the Work, You may choose to conclude an additional agreement, defining obligations or services +consistent with this Licence. However, if accepting obligations, You may act only on your own behalf and on your sole +responsibility, not on behalf of the original Licensor or any other Contributor, and only if You agree to indemnify, +defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against such Contributor by +the fact You have accepted any warranty or additional liability. + +10.Acceptance of the Licence +The provisions of this Licence can be accepted by clicking on an icon ‘I agree’ placed under the bottom of a window +displaying the text of this Licence or by affirming consent in any other similar way, in accordance with the rules of +applicable law. Clicking on that icon indicates your clear and irrevocable acceptance of this Licence and all of its terms +and conditions. +Similarly, you irrevocably accept this Licence and all of its terms and conditions by exercising any rights granted to You +by Article 2 of this Licence, such as the use of the Work, the creation by You of a Derivative Work or the Distribution +or Communication by You of the Work or copies thereof. + +11.Information to the public +In case of any Distribution or Communication of the Work by means of electronic communication by You (for example, +by offering to download the Work from a remote location) the distribution channel or media (for example, a website) +must at least provide to the public the information requested by the applicable law regarding the Licensor, the Licence +and the way it may be accessible, concluded, stored and reproduced by the Licensee. + +12.Termination of the Licence +The Licence and the rights granted hereunder will terminate automatically upon any breach by the Licensee of the terms +of the Licence. +Such a termination will not terminate the licences of any person who has received the Work from the Licensee under +the Licence, provided such persons remain in full compliance with the Licence. + +13.Miscellaneous +Without prejudice of Article 9 above, the Licence represents the complete agreement between the Parties as to the +Work. +If any provision of the Licence is invalid or unenforceable under applicable law, this will not affect the validity or +enforceability of the Licence as a whole. Such provision will be construed or reformed so as necessary to make it valid +and enforceable. +The European Commission may publish other linguistic versions or new versions of this Licence or updated versions of +the Appendix, so far this is required and reasonable, without reducing the scope of the rights granted by the Licence. +New versions of the Licence will be published with a unique version number. +All linguistic versions of this Licence, approved by the European Commission, have identical value. Parties can take +advantage of the linguistic version of their choice. + +14.Jurisdiction +Without prejudice to specific agreement between parties, +— any litigation resulting from the interpretation of this License, arising between the European Union institutions, +bodies, offices or agencies, as a Licensor, and any Licensee, will be subject to the jurisdiction of the Court of Justice +of the European Union, as laid down in article 272 of the Treaty on the Functioning of the European Union, +— any litigation arising between other parties and resulting from the interpretation of this License, will be subject to +the exclusive jurisdiction of the competent court where the Licensor resides or conducts its primary business. + +15.Applicable Law +Without prejudice to specific agreement between parties, +— this Licence shall be governed by the law of the European Union Member State where the Licensor has his seat, +resides or has his registered office, +— this licence shall be governed by Belgian law if the Licensor has no seat, residence or registered office inside +a European Union Member State. + + + Appendix + +‘Compatible Licences’ according to Article 5 EUPL are: +— GNU General Public License (GPL) v. 2, v. 3 +— GNU Affero General Public License (AGPL) v. 3 +— Open Software License (OSL) v. 2.1, v. 3.0 +— Eclipse Public License (EPL) v. 1.0 +— CeCILL v. 2.0, v. 2.1 +— Mozilla Public Licence (MPL) v. 2 +— GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3 +— Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for works other than software +— European Union Public Licence (EUPL) v. 1.1, v. 1.2 +— Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong Reciprocity (LiLiQ-R+). + +The European Commission may update this Appendix to later versions of the above licences without producing +a new version of the EUPL, as long as they provide the rights granted in Article 2 of this Licence and protect the +covered Source Code from exclusive appropriation. +All other changes or additions to this Appendix require the production of a new EUPL version. diff --git a/npm/poindexter-wasm/PROJECT_README.md b/npm/poindexter-wasm/PROJECT_README.md new file mode 100644 index 0000000..ef121b8 --- /dev/null +++ b/npm/poindexter-wasm/PROJECT_README.md @@ -0,0 +1,201 @@ +# Poindexter + +[![Go Reference](https://pkg.go.dev/badge/github.com/Snider/Poindexter.svg)](https://pkg.go.dev/github.com/Snider/Poindexter) +[![CI](https://github.com/Snider/Poindexter/actions/workflows/ci.yml/badge.svg)](https://github.com/Snider/Poindexter/actions) +[![Go Report Card](https://goreportcard.com/badge/github.com/Snider/Poindexter)](https://goreportcard.com/report/github.com/Snider/Poindexter) +[![Vulncheck](https://img.shields.io/badge/govulncheck-enabled-brightgreen.svg)](https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck) +[![codecov](https://codecov.io/gh/Snider/Poindexter/branch/main/graph/badge.svg)](https://codecov.io/gh/Snider/Poindexter) +[![Release](https://img.shields.io/github/v/release/Snider/Poindexter?display_name=tag)](https://github.com/Snider/Poindexter/releases) + +A Go library package providing utility functions including sorting algorithms with custom comparators. + +## Features + +- 🔢 **Sorting Utilities**: Sort integers, strings, and floats in ascending or descending order +- 🎯 **Custom Sorting**: Sort any type with custom comparison functions or key extractors +- 🔍 **Binary Search**: Fast search on sorted data +- 🧭 **KDTree (NN Search)**: Build a KDTree over points with generic payloads; nearest, k-NN, and radius queries with Euclidean, Manhattan, Chebyshev, and Cosine metrics +- 📦 **Generic Functions**: Type-safe operations using Go generics +- ✅ **Well-Tested**: Comprehensive test coverage +- 📖 **Documentation**: Full documentation available at GitHub Pages + +## Installation + +```bash +go get github.com/Snider/Poindexter +``` + +## Quick Start + +```go +package main + +import ( + "fmt" + poindexter "github.com/Snider/Poindexter" +) + +func main() { + // Basic sorting + numbers := []int{3, 1, 4, 1, 5, 9} + poindexter.SortInts(numbers) + fmt.Println(numbers) // [1 1 3 4 5 9] + + // Custom sorting with key function + type Product struct { + Name string + Price float64 + } + + products := []Product{{"Apple", 1.50}, {"Banana", 0.75}, {"Cherry", 3.00}} + poindexter.SortByKey(products, func(p Product) float64 { return p.Price }) + + // KDTree quick demo + pts := []poindexter.KDPoint[string]{ + {ID: "A", Coords: []float64{0, 0}, Value: "alpha"}, + {ID: "B", Coords: []float64{1, 0}, Value: "bravo"}, + {ID: "C", Coords: []float64{0, 1}, Value: "charlie"}, + } + tree, _ := poindexter.NewKDTree(pts, poindexter.WithMetric(poindexter.EuclideanDistance{})) + nearest, dist, _ := tree.Nearest([]float64{0.9, 0.1}) + fmt.Println(nearest.ID, nearest.Value, dist) // B bravo ~0.141... +} +``` + +## Documentation + +Full documentation is available at [https://snider.github.io/Poindexter/](https://snider.github.io/Poindexter/) + +Explore runnable examples in the repository: +- examples/dht_ping_1d +- examples/kdtree_2d_ping_hop +- examples/kdtree_3d_ping_hop_geo +- examples/kdtree_4d_ping_hop_geo_score + +### KDTree performance and notes +- Current KDTree queries are O(n) linear scans, which are great for small-to-medium datasets or low-latency prototyping. For 1e5+ points and low/medium dimensions, consider swapping the internal engine to `gonum.org/v1/gonum/spatial/kdtree` (the API here is compatible by design). +- Insert is O(1) amortized; delete by ID is O(1) via swap-delete; order is not preserved. +- Concurrency: the KDTree type is not safe for concurrent mutation. Protect with a mutex or share immutable snapshots for read-mostly workloads. +- See multi-dimensional examples (ping/hops/geo/score) in docs and `examples/`. +- Performance guide: see docs/Performance for benchmark guidance and tips: [docs/perf.md](docs/perf.md) • Hosted: https://snider.github.io/Poindexter/perf/ + +#### Choosing a metric (quick tips) +- Euclidean (L2): smooth trade-offs across axes; solid default for blended preferences. +- Manhattan (L1): emphasizes per-axis absolute differences; good when each unit of ping/hop matters equally. +- Chebyshev (L∞): dominated by the worst axis; useful for strict thresholds (e.g., reject high hop count regardless of ping). +- Cosine: angle-based for vector similarity; pair it with normalized/weighted features when direction matters more than magnitude. + +See the multi-dimensional KDTree docs for end-to-end examples and weighting/normalization helpers: [Multi-Dimensional KDTree (DHT)](docs/kdtree-multidimensional.md). + +## Maintainer Makefile + +The repository includes a maintainer-friendly `Makefile` that mirrors CI tasks and speeds up local workflows. + +- help — list available targets +- tidy / tidy-check — run `go mod tidy`, optionally verify no diffs +- fmt — format code (`go fmt ./...`) +- vet — `go vet ./...` +- build — `go build ./...` +- examples — build all programs under `examples/` (if present) +- test — run unit tests +- race — run tests with the race detector +- cover — run tests with race + coverage (writes `coverage.out` and prints summary) +- coverhtml — render HTML coverage report to `coverage.html` +- coverfunc — print per-function coverage (from `coverage.out`) +- cover-kdtree — print coverage details filtered to `kdtree.go` +- fuzz — run Go fuzzing for a configurable time (default 10s) matching CI +- bench — run benchmarks with `-benchmem` (writes `bench.txt`) +- lint — run `golangci-lint` (if installed) +- vuln — run `govulncheck` (if installed) +- ci — CI-parity aggregate: tidy-check, build, vet, cover, examples, bench, lint, vuln +- release — run GoReleaser with the canonical `.goreleaser.yaml` (for tagged releases) +- snapshot — GoReleaser snapshot (no publish) +- docs-serve — serve MkDocs locally on 127.0.0.1:8000 +- docs-build — build MkDocs site into `site/` + +Quick usage: + +- See all targets: + +```bash +make help +``` + +- Fast local cycle: + +```bash +make fmt +make vet +make test +``` + +- CI-parity run (what GitHub Actions does, locally): + +```bash +make ci +``` + +- Coverage summary: + +```bash +make cover +``` + +- Generate HTML coverage report (writes coverage.html): + +```bash +make coverhtml +``` + +- Fuzz for 10 seconds (default): + +```bash +make fuzz +``` + +- Fuzz with a custom time (e.g., 30s): + +```bash +make fuzz FUZZTIME=30s +``` + +- Run benchmarks (writes bench.txt): + +```bash +make bench +``` + +- Build examples (if any under ./examples): + +```bash +make examples +``` + +- Serve docs locally (requires mkdocs-material): + +```bash +make docs-serve +``` + +Configurable variables: + +- `FUZZTIME` (default `10s`) — e.g. `make fuzz FUZZTIME=30s` +- `BENCHOUT` (default `bench.txt`), `COVEROUT` (default `coverage.out`), `COVERHTML` (default `coverage.html`) +- Tool commands are overridable via env: `GO`, `GOLANGCI_LINT`, `GORELEASER`, `MKDOCS` + +Requirements for optional targets: + +- `golangci-lint` for `make lint` +- `golang.org/x/vuln/cmd/govulncheck` for `make vuln` +- `goreleaser` for `make release` / `make snapshot` +- `mkdocs` + `mkdocs-material` for `make docs-serve` / `make docs-build` + +See the full Makefile at the repo root for authoritative target definitions. + +## License + +This project is licensed under the European Union Public Licence v1.2 (EUPL-1.2). See [LICENSE](LICENSE) for details. + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. \ No newline at end of file diff --git a/npm/poindexter-wasm/README.md b/npm/poindexter-wasm/README.md new file mode 100644 index 0000000..c86cf2f --- /dev/null +++ b/npm/poindexter-wasm/README.md @@ -0,0 +1,79 @@ +# @snider/poindexter-wasm + +WebAssembly build of the Poindexter KD-Tree library for browsers. Designed to be consumed from Angular, React, or any ESM-capable bundler. + +Status: experimental preview. API surface can evolve. + +## Install + +Until published to npm, you can use a local file/path install: + +```bash +# From the repo root where this folder exists +npm pack ./npm/poindexter-wasm +# Produces a tarball like snider-poindexter-wasm-0.0.0-development.tgz +# In your Angular project: +npm install ../Poindexter/snider-poindexter-wasm-0.0.0-development.tgz +``` + +Once published: + +```bash +npm install @snider/poindexter-wasm +``` + +## Usage (Angular/ESM) + +```ts +// app.module.ts or a dedicated provider file +import { init } from '@snider/poindexter-wasm'; + +async function bootstrapPoindexter() { + const px = await init(); + console.log(await px.version()); + + const tree = await px.newTree(2); + await tree.insert({ id: 'a', coords: [0, 0], value: 'A' }); + await tree.insert({ id: 'b', coords: [1, 1], value: 'B' }); + + const nearest = await tree.nearest([0.2, 0.1]); + console.log('nearest:', nearest); + + return { px, tree }; +} + +// Call bootstrapPoindexter() during app initialization +``` + +If your bundler cannot resolve asset URLs from `import.meta.url`, pass explicit URLs: + +```ts +const px = await init({ + wasmURL: '/assets/poindexter/poindexter.wasm', + wasmExecURL: '/assets/poindexter/wasm_exec.js', +}); +``` + +To host the assets, copy `node_modules/@snider/poindexter-wasm/dist/*` into your app's public/assets folder during build (e.g., with Angular `assets` config in `angular.json`). + +## API + +- `version(): Promise` – Poindexter library version. +- `hello(name?: string): Promise` – simple sanity check. +- `newTree(dim: number): Promise` – create a new KD-Tree with given dimension. + +Tree methods: +- `dim(): Promise` +- `len(): Promise` +- `insert(point: {id: string, coords: number[], value?: string}): Promise` +- `deleteByID(id: string): Promise` +- `nearest(query: number[]): Promise<{point, dist, found}>` +- `kNearest(query: number[], k: number): Promise<{points, dists}>` +- `radius(query: number[], r: number): Promise<{points, dists}>` +- `exportJSON(): Promise` – minimal metadata export for now. + +## Notes + +- Values are strings in this WASM build for simplicity across the boundary. +- This package ships `dist/poindexter.wasm` and Go's `wasm_exec.js`. The loader adds required shims at runtime. +- Requires a modern browser with WebAssembly support. diff --git a/npm/poindexter-wasm/index.d.ts b/npm/poindexter-wasm/index.d.ts new file mode 100644 index 0000000..c57c8c8 --- /dev/null +++ b/npm/poindexter-wasm/index.d.ts @@ -0,0 +1,41 @@ +export interface PxPoint { + id: string; + coords: number[]; + value?: string; +} + +export interface NearestResult { + point: PxPoint; + dist: number; + found: boolean; +} + +export interface KNearestResult { + points: PxPoint[]; + dists: number[]; +} + +export interface PxTree { + len(): Promise; + dim(): Promise; + insert(point: PxPoint): Promise; + deleteByID(id: string): Promise; + nearest(query: number[]): Promise; + kNearest(query: number[], k: number): Promise; + radius(query: number[], r: number): Promise; + exportJSON(): Promise; +} + +export interface InitOptions { + wasmURL?: string; + wasmExecURL?: string; + instantiateWasm?: (source: ArrayBuffer, importObject: WebAssembly.Imports) => Promise | WebAssembly.Instance; +} + +export interface PxAPI { + version(): Promise; + hello(name?: string): Promise; + newTree(dim: number): Promise; +} + +export function init(options?: InitOptions): Promise; diff --git a/npm/poindexter-wasm/loader.cjs b/npm/poindexter-wasm/loader.cjs new file mode 100644 index 0000000..0f1b5f1 --- /dev/null +++ b/npm/poindexter-wasm/loader.cjs @@ -0,0 +1,11 @@ +// CommonJS loader placeholder for @snider/poindexter-wasm +// This package is intended for browser bundlers (Angular/webpack/Vite) using ESM. +// If you are in a CommonJS environment, please switch to ESM import: +// import { init } from '@snider/poindexter-wasm'; +// Or configure your bundler to use the ESM entry. + +module.exports = { + init: function () { + throw new Error("@snider/poindexter-wasm: CommonJS is not supported; use ESM import instead."); + } +}; diff --git a/npm/poindexter-wasm/loader.js b/npm/poindexter-wasm/loader.js new file mode 100644 index 0000000..e58179b --- /dev/null +++ b/npm/poindexter-wasm/loader.js @@ -0,0 +1,89 @@ +// ESM loader for Poindexter WASM +// Usage: +// import { init } from '@snider/poindexter-wasm'; +// const px = await init(); +// const tree = await px.newTree(2); +// await tree.insert({ id: 'a', coords: [0,0], value: 'A' }); +// const res = await tree.nearest([0.1, 0.2]); + +async function loadScriptOnce(src) { + return new Promise((resolve, reject) => { + // If already present, resolve immediately + if (document.querySelector(`script[src="${src}"]`)) return resolve(); + const s = document.createElement('script'); + s.src = src; + s.onload = () => resolve(); + s.onerror = (e) => reject(new Error(`Failed to load ${src}`)); + document.head.appendChild(s); + }); +} + +async function ensureWasmExec(url) { + if (typeof window !== 'undefined' && typeof window.Go === 'function') return; + await loadScriptOnce(url); + if (typeof window === 'undefined' || typeof window.Go !== 'function') { + throw new Error('wasm_exec.js did not define window.Go'); + } +} + +function unwrap(result) { + if (!result || typeof result !== 'object') throw new Error('bad result'); + if (result.ok) return result.data; + throw new Error(result.error || 'unknown error'); +} + +function call(name, ...args) { + const fn = globalThis[name]; + if (typeof fn !== 'function') throw new Error(`WASM function ${name} not found`); + return unwrap(fn(...args)); +} + +class PxTree { + constructor(treeId) { this.treeId = treeId; } + async len() { return call('pxTreeLen', this.treeId); } + async dim() { return call('pxTreeDim', this.treeId); } + async insert(point) { return call('pxInsert', this.treeId, point); } + async deleteByID(id) { return call('pxDeleteByID', this.treeId, id); } + async nearest(query) { return call('pxNearest', this.treeId, query); } + async kNearest(query, k) { return call('pxKNearest', this.treeId, query, k); } + async radius(query, r) { return call('pxRadius', this.treeId, query, r); } + async exportJSON() { return call('pxExportJSON', this.treeId); } +} + +export async function init(options = {}) { + const { + wasmURL = new URL('./dist/poindexter.wasm', import.meta.url).toString(), + wasmExecURL = new URL('./dist/wasm_exec.js', import.meta.url).toString(), + instantiateWasm // optional custom instantiator: (source, importObject) => WebAssembly.Instance + } = options; + + await ensureWasmExec(wasmExecURL); + const go = new window.Go(); + + let result; + if (instantiateWasm) { + const source = await fetch(wasmURL).then(r => r.arrayBuffer()); + const inst = await instantiateWasm(source, go.importObject); + result = { instance: inst }; + } else if (WebAssembly.instantiateStreaming) { + result = await WebAssembly.instantiateStreaming(fetch(wasmURL), go.importObject); + } else { + const resp = await fetch(wasmURL); + const bytes = await resp.arrayBuffer(); + result = await WebAssembly.instantiate(bytes, go.importObject); + } + + // Run the Go program (it registers globals like pxNewTree, etc.) + await go.run(result.instance); + + const api = { + version: async () => call('pxVersion'), + hello: async (name) => call('pxHello', name ?? ''), + newTree: async (dim) => { + const info = call('pxNewTree', dim); + return new PxTree(info.treeId); + } + }; + + return api; +} diff --git a/npm/poindexter-wasm/package.json b/npm/poindexter-wasm/package.json new file mode 100644 index 0000000..86a7c83 --- /dev/null +++ b/npm/poindexter-wasm/package.json @@ -0,0 +1,27 @@ +{ + "name": "@snider/poindexter-wasm", + "version": "0.0.0-development", + "description": "Poindexter KD-Tree WebAssembly build for browsers (Angular/SPA ready)", + "license": "MIT", + "author": "Snider", + "type": "module", + "exports": { + ".": { + "import": "./loader.js", + "require": "./loader.cjs" + } + }, + "module": "./loader.js", + "main": "./loader.cjs", + "types": "./index.d.ts", + "files": [ + "dist/poindexter.wasm", + "dist/wasm_exec.js", + "loader.js", + "loader.cjs", + "index.d.ts", + "README.md", + "LICENSE", + "PROJECT_README.md" + ] +} diff --git a/wasm/main.go b/wasm/main.go new file mode 100644 index 0000000..4f046d8 --- /dev/null +++ b/wasm/main.go @@ -0,0 +1,242 @@ +//go:build js && wasm + +package main + +import ( + "encoding/json" + "errors" + "fmt" + "syscall/js" + + pd "github.com/Snider/Poindexter" +) + +// Simple registry for KDTree instances created from JS. +// We keep values as string for simplicity across the WASM boundary. +var ( + treeRegistry = map[int]*pd.KDTree[string]{} + nextTreeID = 1 +) + +func export(name string, fn func(this js.Value, args []js.Value) (any, error)) { + js.Global().Set(name, js.FuncOf(func(this js.Value, args []js.Value) any { + res, err := fn(this, args) + if err != nil { + return map[string]any{"ok": false, "error": err.Error()} + } + return map[string]any{"ok": true, "data": res} + })) +} + +func getInt(v js.Value, idx int) (int, error) { + if len := v.Length(); len > idx { + return v.Index(idx).Int(), nil + } + return 0, errors.New("missing integer argument") +} + +func getFloatSlice(arg js.Value) ([]float64, error) { + if arg.IsUndefined() || arg.IsNull() { + return nil, errors.New("coords/query is undefined or null") + } + ln := arg.Length() + res := make([]float64, ln) + for i := 0; i < ln; i++ { + res[i] = arg.Index(i).Float() + } + return res, nil +} + +func version(_ js.Value, _ []js.Value) (any, error) { + return pd.Version(), nil +} + +func hello(_ js.Value, args []js.Value) (any, error) { + name := "" + if len(args) > 0 { + name = args[0].String() + } + return pd.Hello(name), nil +} + +func newTree(_ js.Value, args []js.Value) (any, error) { + if len(args) < 1 { + return nil, errors.New("newTree(dim) requires dim") + } + dim := args[0].Int() + if dim <= 0 { + return nil, pd.ErrZeroDim + } + t, err := pd.NewKDTreeFromDim[string](dim) + if err != nil { + return nil, err + } + id := nextTreeID + nextTreeID++ + treeRegistry[id] = t + return map[string]any{"treeId": id, "dim": dim}, nil +} + +func treeLen(_ js.Value, args []js.Value) (any, error) { + if len(args) < 1 { + return nil, errors.New("len(treeId)") + } + id := args[0].Int() + t, ok := treeRegistry[id] + if !ok { + return nil, fmt.Errorf("unknown treeId %d", id) + } + return t.Len(), nil +} + +func treeDim(_ js.Value, args []js.Value) (any, error) { + if len(args) < 1 { + return nil, errors.New("dim(treeId)") + } + id := args[0].Int() + t, ok := treeRegistry[id] + if !ok { + return nil, fmt.Errorf("unknown treeId %d", id) + } + return t.Dim(), nil +} + +func insert(_ js.Value, args []js.Value) (any, error) { + // insert(treeId, {id: string, coords: number[], value?: string}) + if len(args) < 2 { + return nil, errors.New("insert(treeId, point)") + } + id := args[0].Int() + pt := args[1] + pid := pt.Get("id").String() + coords, err := getFloatSlice(pt.Get("coords")) + if err != nil { + return nil, err + } + val := pt.Get("value").String() + t, ok := treeRegistry[id] + if !ok { + return nil, fmt.Errorf("unknown treeId %d", id) + } + okIns := t.Insert(pd.KDPoint[string]{ID: pid, Coords: coords, Value: val}) + return okIns, nil +} + +func deleteByID(_ js.Value, args []js.Value) (any, error) { + // deleteByID(treeId, id) + if len(args) < 2 { + return nil, errors.New("deleteByID(treeId, id)") + } + id := args[0].Int() + pid := args[1].String() + t, ok := treeRegistry[id] + if !ok { + return nil, fmt.Errorf("unknown treeId %d", id) + } + return t.DeleteByID(pid), nil +} + +func nearest(_ js.Value, args []js.Value) (any, error) { + // nearest(treeId, query:number[]) -> {point, dist, found} + if len(args) < 2 { + return nil, errors.New("nearest(treeId, query)") + } + id := args[0].Int() + query, err := getFloatSlice(args[1]) + if err != nil { + return nil, err + } + t, ok := treeRegistry[id] + if !ok { + return nil, fmt.Errorf("unknown treeId %d", id) + } + p, d, found := t.Nearest(query) + out := map[string]any{ + "point": map[string]any{"id": p.ID, "coords": p.Coords, "value": p.Value}, + "dist": d, + "found": found, + } + return out, nil +} + +func kNearest(_ js.Value, args []js.Value) (any, error) { + // kNearest(treeId, query:number[], k:int) -> {points:[...], dists:[...]} + if len(args) < 3 { + return nil, errors.New("kNearest(treeId, query, k)") + } + id := args[0].Int() + query, err := getFloatSlice(args[1]) + if err != nil { + return nil, err + } + k := args[2].Int() + t, ok := treeRegistry[id] + if !ok { + return nil, fmt.Errorf("unknown treeId %d", id) + } + pts, dists := t.KNearest(query, k) + jsPts := make([]any, len(pts)) + for i, p := range pts { + jsPts[i] = map[string]any{"id": p.ID, "coords": p.Coords, "value": p.Value} + } + return map[string]any{"points": jsPts, "dists": dists}, nil +} + +func radius(_ js.Value, args []js.Value) (any, error) { + // radius(treeId, query:number[], r:number) -> {points:[...], dists:[...]} + if len(args) < 3 { + return nil, errors.New("radius(treeId, query, r)") + } + id := args[0].Int() + query, err := getFloatSlice(args[1]) + if err != nil { + return nil, err + } + r := args[2].Float() + t, ok := treeRegistry[id] + if !ok { + return nil, fmt.Errorf("unknown treeId %d", id) + } + pts, dists := t.Radius(query, r) + jsPts := make([]any, len(pts)) + for i, p := range pts { + jsPts[i] = map[string]any{"id": p.ID, "coords": p.Coords, "value": p.Value} + } + return map[string]any{"points": jsPts, "dists": dists}, nil +} + +func exportJSON(_ js.Value, args []js.Value) (any, error) { + // exportJSON(treeId) -> string (all points) + if len(args) < 1 { + return nil, errors.New("exportJSON(treeId)") + } + id := args[0].Int() + t, ok := treeRegistry[id] + if !ok { + return nil, fmt.Errorf("unknown treeId %d", id) + } + // naive export: ask for all points by radius from origin with large r; or keep + // internal slice? KDTree doesn't expose iteration, so skip heavy export here. + // Return metrics only for now. + m := map[string]any{"dim": t.Dim(), "len": t.Len()} + b, _ := json.Marshal(m) + return string(b), nil +} + +func main() { + // Export core API + export("pxVersion", version) + export("pxHello", hello) + export("pxNewTree", newTree) + export("pxTreeLen", treeLen) + export("pxTreeDim", treeDim) + export("pxInsert", insert) + export("pxDeleteByID", deleteByID) + export("pxNearest", nearest) + export("pxKNearest", kNearest) + export("pxRadius", radius) + export("pxExportJSON", exportJSON) + + // Keep running + select {} +}