This commit is contained in:
2024-11-13 01:31:51 -06:00
parent e47540ea4d
commit 2fc47b2554
8 changed files with 888 additions and 99 deletions

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
.cache/
build/
/cmake-*/
compile_commands.json
/dist/
node_modules/

View File

@@ -1,5 +1,13 @@
cmake_minimum_required(VERSION 3.20)
set(CMAKE_POLICY_DEFAULT_CMP0077 NEW)
set(CMAKE_POLICY_DEFAULT_CMP0069 NEW)
project(ecgsyn.js)
option(BUILD_WITH_EMSCRIPTEN "Build with Emscripten" ON)
option(BUILD_SHARED_LIBS "Build shared libraries" OFF)
if(BUILD_WITH_EMSCRIPTEN)
set(EMSCRIPTEN_ROOT $ENV{EMSDK})
if(NOT EMSCRIPTEN_ROOT)
@@ -14,9 +22,8 @@ set(CMAKE_TOOLCHAIN_FILE
"${EMSCRIPTEN_ROOT}/cmake/Modules/Platform/Emscripten.cmake"
CACHE FILEPATH "Emscripten toolchain file")
project(ecgsyn.js)
option(BUILD_SHARED_LIBS "Build shared libraries" OFF)
set(BUILD_SHARED_LIBS OFF FORCE)
endif(BUILD_WITH_EMSCRIPTEN)
set(CMAKE_C_STANDARD 99)
set(CMAKE_CXX_STANDARD 20)
@@ -25,24 +32,40 @@ set(PFFFT_USE_TYPE_FLOAT
OFF
CACHE BOOL "activate pffft float" FORCE)
include(CheckIPOSupported)
check_ipo_supported(RESULT ipo_supported)
if(ipo_supported)
set(CMAKE_INTERPROCEDURAL_OPTIMIZATION_RELEASE ON)
endif()
if(BUILD_WITH_EMSCRIPTEN)
include_directories(BEFORE SYSTEM compat)
add_compile_options(-flto -msimd128 -mavx)
add_link_options(-flto)
add_subdirectory(mini-odeint EXCLUDE_FROM_ALL)
add_subdirectory(pffft EXCLUDE_FROM_ALL)
add_executable(ecgsyn ecgsyn.cpp)
target_compile_options(ecgsyn PRIVATE)
target_link_options(
ecgsyn
PRIVATE
-flto
-sEXPORTED_FUNCTIONS=_ecgsyn
add_compile_options(-msimd128 -msse -mavx -fwasm-exceptions)
set(ecgsyn_link_options # -flto
-fwasm-exceptions
-sEXPORTED_FUNCTIONS=_rr_gen_new,_rr_gen_new_series,_destroy_obj,_ecgsyn,_realloc,_free
--no-entry
-sSTRICT
-sNO_ASSERTIONS
-sNO_FILESYSTEM
-sMALLOC=emmalloc)
target_link_libraries(ecgsyn PRIVATE PFFFT)
else()
set(TARGET_C_ARCH haswell)
set(TARGET_CXX_ARCH haswell)
set(ecgsyn_defs ECGSYN_HOST_BUILD)
endif()
add_subdirectory(mini-odeint EXCLUDE_FROM_ALL)
add_subdirectory(pffft EXCLUDE_FROM_ALL)
add_executable(ecgsyn ecgsyn.cpp)
target_compile_definitions(ecgsyn PRIVATE PFFFT_ENABLE_DOUBLE ${ecgsyn_defs})
target_link_options(ecgsyn PRIVATE ${ecgsyn_link_options})
target_link_libraries(ecgsyn PRIVATE PFFFT mini-odeint)
if(BUILD_WITH_EMSCRIPTEN)
set_target_properties(ecgsyn PROPERTIES SUFFIX ".wasm")
endif(BUILD_WITH_EMSCRIPTEN)

View File

@@ -8,15 +8,36 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/picnic" />
<!-- Place favicon.ico in the root directory -->
</head>
<script type="module">
import load from "../dist/index.js";
import { load, ECGSyn } from "../dist/index.js";
await load();
const ecgsyn = new ECGSyn();
ecgsyn.rrProcess();
ecgsyn.compute();
ecgsyn.update();
let ecgsyn = await load();
window.ecgsn = ecgsyn;
ecgsyn.ecgsyn();
const p = ecgsyn.parameters;
const width = document.getElementById("r_width");
width.value = p.attractors[2].b;
const theta = document.getElementById("p_theta");
theta.value = (p.attractors[0].theta * 180) / Math.PI;
function listener(event) {
p.attractors[0].theta = (theta.value * Math.PI) / 180;
p.attractors[2].b = width.value;
ecgsyn.compute();
ecgsyn.update();
}
for (const el of [theta, width]) {
el.addEventListener("input", listener);
}
</script>
<body>
<!--[if lt IE 8]>
@@ -25,5 +46,15 @@
<a href="http://browsehappy.com/">upgrade your browser</a> to improve your experience.
</p>
<![endif]-->
<canvas width="1900" height="300" id="output"></canvas>
<div>
<select>
<option value="range"></option>
</select>
<div>
<input type="range" min="0" max="1" value="0" step=".01" class="slider" id="r_width" />
<input type="range" min="-180" max="180" value="0" class="slider" id="p_theta" />
</div>
</div>
</body>
</html>

View File

@@ -3,8 +3,8 @@
#include <numeric>
#include <span>
#include "mini-odeint.hpp"
#include "pffft.hpp"
#include "xoshiro.hpp"
#if defined(__clang__)
@@ -29,7 +29,7 @@ FORCE_INLINE constexpr auto sqr(auto &&x) noexcept(noexcept(x * x))
}
template <typename T> inline T stdev(std::span<const T> data) {
const auto n = T{data.size()};
const auto n = static_cast<T>(data.size());
const auto mean = std::accumulate(data.begin(), data.end(), T{}) / n;
const auto variance =
std::accumulate(data.begin(), data.end(), T{},
@@ -38,43 +38,53 @@ template <typename T> inline T stdev(std::span<const T> data) {
return std::sqrt(variance);
}
template <typename T>
constexpr bool is_wasm_bindable_v =
std::conjunction_v<std::is_trivially_copyable<T>,
std::is_standard_layout<T>>;
} // namespace
namespace ecgsyns {
struct TimeParameters {
int num_beats = 12;
int sr_internal = 512;
int decimate_factor = 2;
double hr_mean = 60.0;
double hr_std = 1.0;
std::uint64_t seed = 0;
int num_beats{12};
int sr_internal{512};
int decimate_factor{2};
double hr_mean{60.0};
double hr_std{1.0};
std::uint64_t seed{8};
};
static_assert(is_wasm_bindable_v<TimeParameters>);
struct RRParameters {
double flo = 0.1;
double flostd = 0.01;
double fhi = 0.25;
double fhistd = 0.01;
double lf_hf_ratio = 0.5;
double flo{0.1};
double flostd{0.01};
double fhi{0.25};
double fhistd{0.01};
double lf_hf_ratio{0.5};
};
static_assert(is_wasm_bindable_v<RRParameters>);
template <typename T = double> class RRSeries {
public:
struct Segment {
T end;
T value;
};
template <typename T> class RRSeries {
private:
TimeParameters time_params_;
RRParameters rr_params_;
XoshiroCpp::Xoshiro256Plus rng_;
std::size_t size_;
struct Segment {
T end;
T value;
};
std::vector<Segment> segments_;
public:
RRSeries(TimeParameters time_params, RRParameters rr_params,
XoshiroCpp::Xoshiro256Plus rng, std::span<const T> signal)
: time_params_(std::move(time_params)), rr_params_(std::move(rr_params)),
rng_(std::move(rng)), size_(signal.size()) {
: time_params_{time_params}, rr_params_{rr_params}, rng_{rng},
size_{signal.size()} {
const auto sr = static_cast<T>(time_params_.sr_internal);
@@ -82,16 +92,18 @@ public:
T tecg{};
std::size_t i{};
while (i < size_) {
while (i < signal.size()) {
tecg += signal[i];
segments_.emplace_back(tecg, signal[i]);
i = static_cast<std::size_t>(std::nearbyint(tecg * sr * T{0.5}) *
T{2.0}) +
1;
i = 1 + static_cast<std::size_t>(std::nearbyint(tecg * sr * T{0.5}) *
T{2.0});
}
}
}
constexpr std::size_t segments_size() const { return segments_.size(); }
constexpr const Segment *segments_data() const { return segments_.data(); }
T operator()(T t) const noexcept {
auto lower = std::lower_bound(
segments_.begin(), segments_.end(), t,
@@ -102,30 +114,38 @@ public:
return lower->value;
}
const TimeParameters &timeParams() const noexcept { return time_params_; }
const RRParameters &rrParams() const noexcept { return rr_params_; }
const XoshiroCpp::Xoshiro256Plus &rng() const noexcept { return rng_; }
std::size_t size() const noexcept { return size_; }
[[nodiscard]] const TimeParameters &time_params() const noexcept {
return time_params_;
}
[[nodiscard]] const RRParameters &rr_params() const noexcept {
return rr_params_;
}
XoshiroCpp::Xoshiro256Plus &rng() noexcept { return rng_; }
[[nodiscard]] std::size_t size() const noexcept { return size_; }
[[nodiscard]] std::size_t output_size() const noexcept {
return size_ / time_params_.decimate_factor;
}
};
class FFTException : public std::exception {
public:
FFTException() {}
virtual const char *what() const throw() { return "FFTException"; }
[[nodiscard]] const char *what() const noexcept override {
return "FFTException";
}
};
template <typename T> class RRGenerator {
template <typename T = double> class RRGenerator {
TimeParameters time_params_;
XoshiroCpp::Xoshiro256Plus rng_;
T rr_mean_;
T rr_std_;
std::size_t nrr_;
XoshiroCpp::Xoshiro256Plus rng_;
pffft::Fft<T> fft_;
pffft::AlignedVector<T> signal_;
pffft::AlignedVector<std::complex<T>> spectrum_;
public:
RRGenerator(const TimeParameters &time_params)
explicit RRGenerator(const TimeParameters &time_params)
: time_params_(time_params), rng_(time_params.seed),
rr_mean_(60.0 / time_params.hr_mean),
rr_std_(60.0 * time_params.hr_std / sqr(time_params.hr_mean)),
@@ -140,7 +160,20 @@ public:
}
}
std::vector<T> generateSignal(const RRParameters &params) {
constexpr std::size_t nrr() const noexcept { return nrr_; }
constexpr const TimeParameters &time_params() const noexcept {
return time_params_;
}
RRSeries<T> generate(const RRParameters &params) {
auto buf = std::make_unique_for_overwrite<T[]>(nrr_);
std::span signal{buf.get(), nrr_};
generate_signal(params, signal);
return {time_params_, params, rng_, signal};
}
void generate_signal(const RRParameters &params, std::span<T> output) {
const T w1 = T{2.0 * M_PI} * params.flo;
const T w2 = T{2.0 * M_PI} * params.fhi;
const T c1 = T{2.0 * M_PI} * params.flostd;
@@ -151,10 +184,10 @@ public:
const T sr = time_params_.sr_internal;
const T dw = (sr / T{nrr_}) * T{2.0 * M_PI};
const T dw = (sr / T(nrr_)) * T{2.0 * M_PI};
for (std::size_t i{}; auto &p : spectrum_) {
const T w = dw * T{i};
const T w = dw * T(i);
const T dw1 = w - w1;
const T dw2 = w - w2;
@@ -176,34 +209,272 @@ public:
fft_.inverse(spectrum_, signal_);
std::ranges::transform(signal_, signal_.begin(),
[this](T x) { return x * T{1.0} / T{nrr_}; });
const T xstd = stdev(signal_);
[this](T x) { return x * T(1.0 / nrr_); });
const T xstd = stdev<T>(signal_);
const T ratio = rr_std_ / xstd;
std::vector<T> result;
result.reserve(nrr_);
std::ranges::transform(signal_, std::back_inserter(result),
std::ranges::transform(signal_, output.begin(),
[ratio, this](T x) { return x * ratio + rr_mean_; });
return result;
}
};
template <typename T = double> struct Attractor {
T theta;
T a;
T b;
T theta_rf;
constexpr static Attractor make(T degrees, T a, T b, T rf = 0.0) {
return {degrees * M_PI / 180.0, a, b, rf};
}
};
template <typename T = double, typename U = std::vector<Attractor<T>>>
struct Parameters {
std::pair<const T, const T> range{-0.4, 1.2};
T noise_amplitude{};
U attractors;
};
template <typename T> struct Parameters<T, std::vector<Attractor<T>>> {
std::pair<T, T> range{-0.4, 1.2};
T noise_amplitude{};
std::vector<Attractor<T>> attractors{
Attractor<T>::make(-70.0, 1.2, 0.25, 0.25),
Attractor<T>::make(-15.0, -5.0, 0.1, 0.5),
Attractor<T>::make(0.0, 30, 0.1),
Attractor<T>::make(15.0, -7.5, 0.1, 0.5),
Attractor<T>::make(100.0, 0.75, 0.4, 0.25),
};
};
template <typename T, typename U = std::vector<Attractor<T>>>
std::size_t generate(const Parameters<T, U> &params, RRSeries<T> &rr_series,
std::span<T> zresult) {
struct ExPoint {
T ti;
T ai;
T bi;
};
auto &rng = rr_series.rng();
const auto sr_internal{rr_series.time_params().sr_internal};
const T hr_sec{rr_series.time_params().hr_mean / 60.0};
const T hr_fact{std::sqrt(hr_sec)};
// adjust extrema parameters for mean heart rate
std::vector<ExPoint> ex;
ex.reserve(params.attractors.size());
for (const auto &a : params.attractors) {
ex.emplace_back(a.theta * std::pow(hr_sec, a.theta_rf), a.a, a.b * hr_fact);
}
const T fhi{rr_series.rr_params().fhi};
const auto nt{rr_series.size()};
const T dt{1.0 / static_cast<T>(sr_internal)};
std::vector<T> ts;
ts.reserve(nt);
for (std::size_t i{}; i < nt; ++i) {
ts.emplace_back(static_cast<T>(i) * dt);
}
using namespace mini_odeint;
Vec3<T> x0{1.0, 0.0, 0.04};
std::vector<Vec3<T>> ys(nt);
explicitRungeKutta<Vec3<T>>(
std::span{ys}, std::span<const T>{ts}, x0, 1e-6, [&](Vec3<T> v, T t) {
const T ta{std::atan2(v.y, v.x)};
const T r0{1.0};
const T a0{T{1.0} - std::sqrt(sqr(v.x) + sqr(v.y)) / r0};
const T w0{T{2.0 * M_PI} / rr_series(t)};
const T zbase{T{0.005} * std::sin(T{2.0 * M_PI} * fhi * t)};
Vec3<T> f{a0 * v.x - w0 * v.y, a0 * v.y + w0 * v.x, 0.0};
for (const auto &e : ex) {
const T dt{std::remainder(ta - e.ti, T{2.0 * M_PI})};
f.z += -e.ai * dt * std::exp(T{-0.5} * sqr(dt) / sqr(e.bi));
}
f.z += T{-1.0} * (v.z + zbase);
return f;
});
// extract z and downsample to output rate
for (std::size_t i{}, j{}; i < nt && j < zresult.size();
i += rr_series.time_params().decimate_factor, ++j) {
zresult[j] = ys[i].z;
}
const auto [zmin, zmax] = std::ranges::minmax(zresult);
const T zrange = zmax - zmin;
// Scale signal between -0.4 and 1.2 mV
// add uniformly distributed measurement noise
std::ranges::transform(zresult, zresult.begin(), [&](T z) {
return (params.range.second - params.range.first) * (z - zmin) / zrange +
params.range.first +
T{2.0 * XoshiroCpp::DoubleFromBits(rng()) - 1.0} *
params.noise_amplitude;
});
return ys.size() / rr_series.time_params().decimate_factor;
}
} // namespace ecgsyns
namespace {
template <typename T> class WasmSpan {
public:
T *ptr;
std::size_t n;
public:
using iterator = T *;
constexpr std::size_t size() const noexcept { return n; }
constexpr iterator begin() const noexcept { return ptr; }
constexpr iterator end() const noexcept { return ptr + n; }
};
class WrapperBase {
public:
virtual ~WrapperBase() = default;
WrapperBase(const WrapperBase &) = delete;
WrapperBase &operator=(const WrapperBase &) = delete;
protected:
WrapperBase() = default;
};
template <typename T> struct Wrapper : public WrapperBase {
T value;
explicit Wrapper(T value) : value{std::move(value)} {}
};
struct WObject {
enum class type : std::uint32_t {
kRRGenerator = 1,
kRRSeries,
} t;
WObject(type t) : t{t} {}
};
struct WRRGenerator : WObject {
std::size_t nrr;
std::size_t output_size;
WRRGenerator(std::size_t nrr, std::size_t output_size)
: WObject{type::kRRGenerator}, nrr{nrr}, output_size{output_size} {}
};
struct NRRGenerator : WRRGenerator {
ecgsyns::RRGenerator<double> obj;
NRRGenerator(ecgsyns::RRGenerator<double> o)
: WRRGenerator{o.nrr(), o.nrr() / o.time_params().decimate_factor},
obj{std::move(o)} {}
};
struct WRRSeries : WObject {
std::size_t size;
std::size_t output_size;
std::size_t nsegments;
const ecgsyns::RRSeries<double>::Segment *segments;
WRRSeries(std::size_t size, std::size_t output_size, std::size_t nsegments,
const ecgsyns::RRSeries<double>::Segment *segments)
: WObject{type::kRRSeries}, size{size}, output_size{output_size},
nsegments{nsegments}, segments{segments} {}
};
struct NRRSeries : WRRSeries {
ecgsyns::RRSeries<double> obj;
NRRSeries(ecgsyns::RRSeries<double> o)
: WRRSeries{o.size(), o.output_size(), o.segments_size(),
o.segments_data()},
obj{std::move(o)} {}
};
} // namespace
#include <span>
extern "C" {
int ecgsyn() {
using namespace ecgsyns;
RRGenerator<float> rr_gen{TimeParameters{}};
const auto rr = rr_gen.generateSignal(RRParameters{});
WRRGenerator *rr_gen_new(const TimeParameters *time_params) {
return new NRRGenerator{ecgsyns::RRGenerator<double>{*time_params}};
}
RRSeries<float> rr_series{TimeParameters{}, RRParameters{},
XoshiroCpp::Xoshiro256Plus{}, rr};
WRRSeries *rr_gen_new_series(WRRGenerator *generator,
const RRParameters *params) {
return new NRRSeries{
static_cast<NRRGenerator *>(generator)->obj.generate(*params)};
}
std::printf("%f", rr_series(0.0));
// XoshiroCpp::DoubleFromBits();
std::printf("hello world\n");
return 69;
void rr_gen_generate(WRRGenerator *generator, const RRParameters *params,
double *output) {
auto &rr = static_cast<NRRGenerator *>(generator)->obj;
rr.generate_signal(*params, {output, rr.nrr()});
}
void destroy_obj(const WObject *o) {
switch (o->t) {
case WObject::type::kRRGenerator:
delete static_cast<const NRRGenerator *>(o);
break;
case WObject::type::kRRSeries:
delete static_cast<const NRRSeries *>(o);
break;
}
}
using WasmParameters = Parameters<double, WasmSpan<const Attractor<double>>>;
static_assert(is_wasm_bindable_v<WasmParameters>);
void ecgsyn(const WasmParameters *params, WRRSeries *rr_series,
double *output) {
auto &rr = static_cast<NRRSeries *>(rr_series)->obj;
ecgsyns::generate(*params, rr, {output, rr.output_size()});
}
}
#if defined(ECGSYN_HOST_BUILD)
#include <fstream>
int main() {
using namespace ecgsyns;
TimeParameters time_params{};
time_params.num_beats = 4;
auto rr = rr_init(&time_params);
const RRParameters rr_params{};
auto rrs = rr_generate(rr, &rr_params);
Parameters params;
std::vector<double> output(rrs->value.output_size());
generate(params, rrs->value, std::span(output));
std::ofstream f{"ecg.csv"};
for (const auto &x : output) {
f << x << '\n';
}
return 0;
}
#endif

View File

@@ -62,10 +62,15 @@ template <typename T> struct DormandPrince {
90730570.0 / 29380423.0, -8293050.0 / 29380423.0}}};
};
template <typename T> struct scalar_type {
template <typename T, typename = void> struct scalar_type {
using type = T;
};
template <typename T>
struct scalar_type<T, std::void_t<typename T::value_type>> {
using type = typename T::value_type;
};
template <typename T, std::size_t N> struct scalar_type<T[N]> {
using type = T;
};
@@ -269,10 +274,6 @@ inline std::floating_point auto inf_norm(std::floating_point auto v) {
return std::abs(v);
}
template <typename T> inline auto inf_norm(const OdeVector<T> &v) {
return inf_norm(static_cast<const T &>(v));
}
template <typename E> inline E inf_norm(const Vec2<E> &v) {
return std::max(std::abs(v.x), std::abs(v.y));
}
@@ -281,6 +282,10 @@ template <typename E> inline E inf_norm(const Vec3<E> &v) {
return std::max({std::abs(v.x), std::abs(v.y), std::abs(v.z)});
}
template <typename T> inline auto inf_norm(const OdeVector<T> &v) {
return inf_norm(static_cast<const T &>(v));
}
} // namespace func
template <typename Vector, typename Scalar = scalar_type_t<Vector>,

View File

@@ -11,16 +11,18 @@
"./ecgsyn.wasm": "./dist/ecgsyn.wasm"
},
"scripts": {
"build": "cmake --build build && tsc && cp -f build/ecgsyn.wasm ./dist",
"build": "cmake --build cmake-build-release-wasm && tsc && cp -f cmake-build-release-wasm/ecgsyn.wasm ./dist",
"typecheck": "tsc --noEmit",
"lint": "eslint .",
"format": "prettier --write \"*.{js,ts,json,css,yml,yaml}\" \"**/*.{js,ts,json,css,yml.yaml}\"",
"serve": "http-server -c-1",
"test": "vitest"
"test": "vitest",
"watch": "tsc -w"
},
"devDependencies": {
"@eslint/js": "^9.14.0",
"http-server": "^14.1.1",
"nodemon": "^3.1.7",
"prettier": "^3.3.3",
"typescript": "^5.6.3",
"typescript-eslint": "^8.13.0"

141
pnpm-lock.yaml generated
View File

@@ -14,6 +14,9 @@ importers:
http-server:
specifier: ^14.1.1
version: 14.1.1
nodemon:
specifier: ^3.1.7
version: 3.1.7
prettier:
specifier: ^3.3.3
version: 3.3.3
@@ -172,6 +175,10 @@ packages:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
anymatch@3.1.3:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'}
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
@@ -185,6 +192,10 @@ packages:
resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==}
engines: {node: '>= 0.8'}
binary-extensions@2.3.0:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'}
brace-expansion@1.1.11:
resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==}
@@ -207,6 +218,10 @@ packages:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
chokidar@3.6.0:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@@ -350,6 +365,11 @@ packages:
debug:
optional: true
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
@@ -375,6 +395,10 @@ packages:
graphemer@1.4.0:
resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
has-flag@3.0.0:
resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==}
engines: {node: '>=4'}
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
@@ -415,6 +439,9 @@ packages:
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
engines: {node: '>=0.10.0'}
ignore-by-default@1.0.1:
resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==}
ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
@@ -427,6 +454,10 @@ packages:
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
engines: {node: '>=0.8.19'}
is-binary-path@2.1.0:
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
engines: {node: '>=8'}
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
@@ -505,6 +536,15 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
nodemon@3.1.7:
resolution: {integrity: sha512-hLj7fuMow6f0lbB0cD14Lz2xNjwsyruH251Pk4t/yIitCFJbmY1myuLlHm/q06aST4jg6EgAh74PIBBrRqpVAQ==}
engines: {node: '>=10'}
hasBin: true
normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
object-inspect@1.13.3:
resolution: {integrity: sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==}
engines: {node: '>= 0.4'}
@@ -554,6 +594,9 @@ packages:
engines: {node: '>=14'}
hasBin: true
pstree.remy@1.1.8:
resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==}
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
@@ -565,6 +608,10 @@ packages:
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
readdirp@3.6.0:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
requires-port@1.0.0:
resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
@@ -609,10 +656,18 @@ packages:
resolution: {integrity: sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==}
engines: {node: '>= 0.4'}
simple-update-notifier@2.0.0:
resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==}
engines: {node: '>=10'}
strip-json-comments@3.1.1:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
supports-color@5.5.0:
resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==}
engines: {node: '>=4'}
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
@@ -624,6 +679,10 @@ packages:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
touch@3.1.1:
resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==}
hasBin: true
ts-api-utils@1.4.0:
resolution: {integrity: sha512-032cPxaEKwM+GT3vA5JXNzIaizx388rhsSW79vGRNGXfRRAdEAn2mvk36PvK5HnOchyWZ7afLEXqYCvPCrzuzQ==}
engines: {node: '>=16'}
@@ -648,6 +707,9 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
undefsafe@2.0.5:
resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==}
union@0.5.0:
resolution: {integrity: sha512-N6uOhuW6zO95P3Mel2I2zMsbsanvvtgn6jVqJv4vbVcz/JN0OkL9suomjQGmWtxJQXOCqUJvquc1sMeNz/IwlA==}
engines: {node: '>= 0.8.0'}
@@ -687,7 +749,7 @@ snapshots:
'@eslint/config-array@0.18.0':
dependencies:
'@eslint/object-schema': 2.1.4
debug: 4.3.7
debug: 4.3.7(supports-color@5.5.0)
minimatch: 3.1.2
transitivePeerDependencies:
- supports-color
@@ -697,7 +759,7 @@ snapshots:
'@eslint/eslintrc@3.1.0':
dependencies:
ajv: 6.12.6
debug: 4.3.7
debug: 4.3.7(supports-color@5.5.0)
espree: 10.3.0
globals: 14.0.0
ignore: 5.3.2
@@ -769,7 +831,7 @@ snapshots:
'@typescript-eslint/types': 8.13.0
'@typescript-eslint/typescript-estree': 8.13.0(typescript@5.6.3)
'@typescript-eslint/visitor-keys': 8.13.0
debug: 4.3.7
debug: 4.3.7(supports-color@5.5.0)
eslint: 9.14.0
optionalDependencies:
typescript: 5.6.3
@@ -785,7 +847,7 @@ snapshots:
dependencies:
'@typescript-eslint/typescript-estree': 8.13.0(typescript@5.6.3)
'@typescript-eslint/utils': 8.13.0(eslint@9.14.0)(typescript@5.6.3)
debug: 4.3.7
debug: 4.3.7(supports-color@5.5.0)
ts-api-utils: 1.4.0(typescript@5.6.3)
optionalDependencies:
typescript: 5.6.3
@@ -799,7 +861,7 @@ snapshots:
dependencies:
'@typescript-eslint/types': 8.13.0
'@typescript-eslint/visitor-keys': 8.13.0
debug: 4.3.7
debug: 4.3.7(supports-color@5.5.0)
fast-glob: 3.3.2
is-glob: 4.0.3
minimatch: 9.0.5
@@ -843,6 +905,11 @@ snapshots:
dependencies:
color-convert: 2.0.1
anymatch@3.1.3:
dependencies:
normalize-path: 3.0.0
picomatch: 2.3.1
argparse@2.0.1: {}
async@2.6.4:
@@ -855,6 +922,8 @@ snapshots:
dependencies:
safe-buffer: 5.1.2
binary-extensions@2.3.0: {}
brace-expansion@1.1.11:
dependencies:
balanced-match: 1.0.2
@@ -883,6 +952,18 @@ snapshots:
ansi-styles: 4.3.0
supports-color: 7.2.0
chokidar@3.6.0:
dependencies:
anymatch: 3.1.3
braces: 3.0.3
glob-parent: 5.1.2
is-binary-path: 2.1.0
is-glob: 4.0.3
normalize-path: 3.0.0
readdirp: 3.6.0
optionalDependencies:
fsevents: 2.3.3
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
@@ -903,9 +984,11 @@ snapshots:
dependencies:
ms: 2.1.3
debug@4.3.7:
debug@4.3.7(supports-color@5.5.0):
dependencies:
ms: 2.1.3
optionalDependencies:
supports-color: 5.5.0
deep-is@0.1.4: {}
@@ -949,7 +1032,7 @@ snapshots:
ajv: 6.12.6
chalk: 4.1.2
cross-spawn: 7.0.5
debug: 4.3.7
debug: 4.3.7(supports-color@5.5.0)
escape-string-regexp: 4.0.0
eslint-scope: 8.2.0
eslint-visitor-keys: 4.2.0
@@ -1032,6 +1115,9 @@ snapshots:
follow-redirects@1.15.9: {}
fsevents@2.3.3:
optional: true
function-bind@1.1.2: {}
get-intrinsic@1.2.4:
@@ -1058,6 +1144,8 @@ snapshots:
graphemer@1.4.0: {}
has-flag@3.0.0: {}
has-flag@4.0.0: {}
has-property-descriptors@1.0.2:
@@ -1109,6 +1197,8 @@ snapshots:
dependencies:
safer-buffer: 2.1.2
ignore-by-default@1.0.1: {}
ignore@5.3.2: {}
import-fresh@3.3.0:
@@ -1118,6 +1208,10 @@ snapshots:
imurmurhash@0.1.4: {}
is-binary-path@2.1.0:
dependencies:
binary-extensions: 2.3.0
is-extglob@2.1.1: {}
is-glob@4.0.3:
@@ -1182,6 +1276,21 @@ snapshots:
natural-compare@1.4.0: {}
nodemon@3.1.7:
dependencies:
chokidar: 3.6.0
debug: 4.3.7(supports-color@5.5.0)
ignore-by-default: 1.0.1
minimatch: 3.1.2
pstree.remy: 1.1.8
semver: 7.6.3
simple-update-notifier: 2.0.0
supports-color: 5.5.0
touch: 3.1.1
undefsafe: 2.0.5
normalize-path@3.0.0: {}
object-inspect@1.13.3: {}
opener@1.5.2: {}
@@ -1225,6 +1334,8 @@ snapshots:
prettier@3.3.3: {}
pstree.remy@1.1.8: {}
punycode@2.3.1: {}
qs@6.13.0:
@@ -1233,6 +1344,10 @@ snapshots:
queue-microtask@1.2.3: {}
readdirp@3.6.0:
dependencies:
picomatch: 2.3.1
requires-port@1.0.0: {}
resolve-from@4.0.0: {}
@@ -1273,8 +1388,16 @@ snapshots:
get-intrinsic: 1.2.4
object-inspect: 1.13.3
simple-update-notifier@2.0.0:
dependencies:
semver: 7.6.3
strip-json-comments@3.1.1: {}
supports-color@5.5.0:
dependencies:
has-flag: 3.0.0
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
@@ -1285,6 +1408,8 @@ snapshots:
dependencies:
is-number: 7.0.0
touch@3.1.1: {}
ts-api-utils@1.4.0(typescript@5.6.3):
dependencies:
typescript: 5.6.3
@@ -1306,6 +1431,8 @@ snapshots:
typescript@5.6.3: {}
undefsafe@2.0.5: {}
union@0.5.0:
dependencies:
qs: 6.13.0

View File

@@ -2,7 +2,13 @@ export interface Exports extends WebAssembly.Exports {
memory: WebAssembly.Memory;
_initialize(): void;
ecgsyn(): number;
rr_gen_new(time_params: number): number;
rr_gen_new_series(gen: number, params: number): number;
rr_gen_generate(gen: number, params: number, output: number): void;
destroy_obj(obj: number): void;
ecgsyn(params: number, rr_series: number, output: number): void;
realloc(ptr: number, size: number): number;
free(ptr: number): void;
}
let exports: Exports;
@@ -103,7 +109,207 @@ class WASI {
}
}
export default async function load(): Promise<Exports> {
export interface Attractor {
theta: number;
a: number;
b: number;
thetaRf: number;
}
export function attractor(deg: number, a: number, b: number, thetaRf = 0): Attractor {
return { theta: (deg * Math.PI) / 180.0, a, b, thetaRf };
}
const freeRegistry = new FinalizationRegistry((ptr: number) => exports.free(ptr));
type Realloc = (p: number, s: number) => number;
export class TimeParameters {
// layout: (num_beats: i32, sr_internal: i32, decimate_factor: i32, hr_mean: f64, hr_std: f64, seed: i64)
static readonly #SIZE = 40;
#view: DataView;
constructor(exports: Exports) {
const ptr = exports.realloc(0, TimeParameters.#SIZE);
freeRegistry.register(this, ptr);
this.#view = new DataView(exports.memory.buffer, ptr, TimeParameters.#SIZE);
this.numBeats = 12;
this.srInternal = 512;
this.decimateFactor = 2;
this.hrMean = 60.0;
this.hrStd = 1.0;
this.seed = BigInt(8);
}
set numBeats(value: number) {
this.#view.setInt32(0, value, true);
}
get numBeats(): number {
return this.#view.getInt32(0, true);
}
set srInternal(value: number) {
this.#view.setInt32(4, value, true);
}
get srInternal(): number {
return this.#view.getInt32(4, true);
}
set decimateFactor(value: number) {
this.#view.setInt32(8, value, true);
}
get decimateFactor(): number {
return this.#view.getInt32(8, true);
}
set hrMean(value: number) {
this.#view.setFloat64(16, value, true);
}
get hrMean(): number {
return this.#view.getFloat64(16, true);
}
set hrStd(value: number) {
this.#view.setFloat64(24, value, true);
}
get hrStd(): number {
return this.#view.getFloat64(24, true);
}
set seed(value: bigint) {
this.#view.setBigUint64(32, value, true);
}
get seed(): bigint {
return this.#view.getBigInt64(32, true);
}
get _ptr(): number {
return this.#view.byteOffset;
}
}
export class RRParameters {
// layout: (flo: f64, flostd: f64, fhi: f64, fhistd: f64, lf_hf_ratio: f64)
static readonly #SIZE = 40;
#view: DataView;
constructor(exports: Exports) {
const ptr = exports.realloc(0, RRParameters.#SIZE);
freeRegistry.register(this, ptr);
this.#view = new DataView(exports.memory.buffer, ptr, RRParameters.#SIZE);
this.flo = 0.04;
this.flostd = 0.01;
this.fhi = 0.15;
this.fhistd = 0.025;
this.lfHfRatio = 1.0;
}
set flo(value: number) {
this.#view.setFloat64(0, value, true);
}
get flo(): number {
return this.#view.getFloat64(0, true);
}
set flostd(value: number) {
this.#view.setFloat64(8, value, true);
}
get flostd(): number {
return this.#view.getFloat64(8, true);
}
set fhi(value: number) {
this.#view.setFloat64(16, value, true);
}
get fhi(): number {
return this.#view.getFloat64(16, true);
}
set fhistd(value: number) {
this.#view.setFloat64(24, value, true);
}
get fhistd(): number {
return this.#view.getFloat64(24, true);
}
set lfHfRatio(value: number) {
this.#view.setFloat64(32, value, true);
}
get lfHfRatio(): number {
return this.#view.getFloat64(32, true);
}
get _ptr(): number {
return this.#view.byteOffset;
}
}
export class Parameters {
// layout (min: f64, max: f64, noiseAmplitude: f64, ptr: i32, n: i32)
static readonly #SIZE = 32;
static readonly #ATTRACTOR_SIZE = 32;
attractors: Attractor[] = [
attractor(-70.0, 1.2, 0.25, 0.25),
attractor(-15.0, -5.0, 0.1, 0.5),
attractor(0.0, 30.0, 0.1),
attractor(15.0, -7.5, 0.1, 0.5),
attractor(100.0, 0.75, 0.4, 0.25),
];
#view: DataView;
readonly #realloc: Realloc;
static #size(n: number): number {
return Parameters.#SIZE + n * Parameters.#ATTRACTOR_SIZE;
}
constructor(exports: Exports) {
this.#realloc = exports.realloc.bind(exports);
const sz = Parameters.#size(this.attractors.length);
const ptr = this.#realloc(0, sz);
freeRegistry.register(this, ptr, this);
this.#view = new DataView(exports.memory.buffer, ptr, sz);
this.#view.setUint32(24, this.#view.byteOffset + Parameters.#SIZE, true);
this.range = { min: -0.4, max: 1.2 };
this.noiseAmplitude = 0.01;
}
set range(value: { min: number; max: number }) {
this.#view.setFloat64(0, value.min, true);
this.#view.setFloat64(8, value.max, true);
}
get range(): { min: number; max: number } {
return { min: this.#view.getFloat64(0, true), max: this.#view.getFloat64(8, true) };
}
set noiseAmplitude(value: number) {
this.#view.setFloat64(16, value, true);
}
get noiseAmplitude(): number {
return this.#view.getFloat64(16, true);
}
#setAttractors(): void {
if (this.#view.byteLength < Parameters.#size(this.attractors.length)) {
const sz = Parameters.#size(this.attractors.length);
const ptr = this.#realloc(this.#view.byteOffset, sz);
freeRegistry.unregister(this);
freeRegistry.register(this, ptr, this);
this.#view = new DataView(this.#view.buffer, ptr, sz);
}
this.#view.setUint32(28, this.attractors.length, true);
let offset = Parameters.#SIZE;
for (const a of this.attractors) {
this.#view.setFloat64(offset, a.theta, true);
this.#view.setFloat64(offset + 8, a.a, true);
this.#view.setFloat64(offset + 16, a.b, true);
this.#view.setFloat64(offset + 24, a.thetaRf, true);
offset += Parameters.#ATTRACTOR_SIZE;
}
}
get _ptr(): number {
this.#setAttractors();
return this.#view.byteOffset;
}
}
export async function load(): Promise<Exports> {
if (!exports) {
const url = new URL("./ecgsyn.wasm", import.meta.url);
const wasi = new WASI();
@@ -114,3 +320,126 @@ export default async function load(): Promise<Exports> {
}
return exports;
}
const destroyRegistry = new FinalizationRegistry((ptr: number) => exports.destroy_obj(ptr));
export class ECGSyn {
#rrGenPtr: number;
#rrSeriesPtr: number;
#signal: Float64Array;
#view: DataView;
readonly timeParams: TimeParameters;
readonly rrParams: RRParameters;
readonly parameters: Parameters;
constructor() {
this.timeParams = new TimeParameters(exports);
this.rrParams = new RRParameters(exports);
this.parameters = new Parameters(exports);
this.#rrGenPtr = 0;
this.#rrSeriesPtr = 0;
this.#signal = new Float64Array(0);
this.#view = new DataView(exports.memory.buffer);
}
rrProcess(): void {
const timeParams = this.timeParams;
const rrParams = this.rrParams;
timeParams.decimateFactor = 1;
timeParams.hrMean = 60;
timeParams.numBeats = 10;
timeParams.seed = BigInt(20);
this.#rrGenPtr = exports.rr_gen_new(timeParams._ptr);
destroyRegistry.register(this, this.#rrGenPtr);
this.#rrSeriesPtr = exports.rr_gen_new_series(this.#rrGenPtr, rrParams._ptr);
destroyRegistry.register(this, this.#rrSeriesPtr);
const length = this.#view.getUint32(this.#rrSeriesPtr + 4, true);
const mem = exports.realloc(0, length * Float64Array.BYTES_PER_ELEMENT);
this.#signal = new Float64Array(exports.memory.buffer, mem, length);
}
compute(): void {
const params = this.parameters;
params.noiseAmplitude = 0.0;
exports.ecgsyn(params._ptr, this.#rrSeriesPtr, this.#signal.byteOffset);
}
update(): void {
const data = this.#signal;
const canvas = document.getElementById("output") as HTMLCanvasElement;
const ctx = canvas.getContext("2d") as CanvasRenderingContext2D;
ctx.fillStyle = "white";
ctx.fillRect(0, 0, canvas.width, canvas.height);
const minValue = -0.4;
const maxValue = 1.2;
const valueRange = maxValue - minValue;
const padding = 40;
const plotWidth = canvas.width - padding * 2;
const plotHeight = canvas.height - padding * 2;
ctx.strokeStyle = "black";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(padding, padding);
ctx.lineTo(padding, canvas.height - padding);
ctx.lineTo(canvas.width - padding, canvas.height - padding);
ctx.stroke();
ctx.fillStyle = "black";
ctx.font = "12px sans-serif";
for (let i = 0; i < 8; i++) {
const value = minValue + (valueRange * i) / 8;
const y = canvas.height - padding - (i * plotHeight) / 8;
ctx.fillText(value.toFixed(1), padding - 30, y + 4);
ctx.strokeStyle = "lightgray";
ctx.beginPath();
ctx.moveTo(padding, y);
ctx.lineTo(canvas.width - padding, y);
ctx.stroke();
}
// X axis labels
for (let i = 0; i < 8; i++) {
const x = padding + (i * plotWidth) / 8;
const value = ((i * data.length) / (8 * 512)).toFixed(2);
ctx.fillText(value, x - 10, canvas.height - padding + 20);
// Grid lines
ctx.strokeStyle = "lightgray";
ctx.beginPath();
ctx.moveTo(x, padding);
ctx.lineTo(x, canvas.height - padding);
ctx.stroke();
}
ctx.strokeStyle = "#2563eb";
ctx.lineWidth = 1;
ctx.beginPath();
const initialX = padding;
const initialY = canvas.height - padding - ((data[0] - minValue) * plotHeight) / valueRange;
ctx.moveTo(initialX, initialY);
for (let i = 1; i < data.length; i++) {
const x = padding + (i * plotWidth) / data.length;
const y = canvas.height - padding - ((data[i] - minValue) * plotHeight) / valueRange;
ctx.lineTo(x, y);
}
ctx.stroke();
}
}