@grunwaldlab/heat-tree 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2869,7 +2869,7 @@ function zoom() {
2869
2869
  };
2870
2870
  return zoom2;
2871
2871
  }
2872
- const styles = ".ht-widget{display:flex;flex-direction:column;width:100%;height:100%}.ht-toolbar{flex:0 0 auto;margin-bottom:4px;display:flex;flex-direction:column;position:relative}.ht-toggle-container{position:absolute;top:0;right:0;padding:4px 8px;pointer-events:none}.ht-control-panel-toggle{background-color:transparent;border:none;cursor:pointer;padding:4px;display:flex;align-items:center;gap:4px;transition:opacity .2s;pointer-events:auto}.ht-control-panel-toggle:hover{opacity:.7}.ht-control-panel-toggle .ht-toggle-arrow{transition:transform .3s}.ht-control-panel-toggle.collapsed .ht-toggle-arrow{transform:rotate(180deg)}.ht-control-panel-toggle .ht-hamburger-icon{color:#333}.ht-collapsible-panel{max-height:1000px;transition:max-height .3s ease-in-out}.ht-collapsible-panel.ht-panel-collapsed{max-height:0}.ht-tabs{display:flex;gap:20px;padding:4px 8px;background-color:#f5f5f5;border-bottom:2px solid #ddd;-webkit-user-select:none;user-select:none}.ht-tab{cursor:pointer;padding:2px 4px;font-family:sans-serif;font-size:14px;color:#333;border-bottom:2px solid transparent;transition:all .2s}.ht-tab:hover{color:#666}.ht-tab.active{color:#000;font-weight:700;border-bottom-color:#007bff}.ht-tab.active:hover{color:#000}.ht-tab.disabled{color:#999;cursor:not-allowed;opacity:.5}.ht-tab.disabled:hover{color:#999}.ht-controls{padding:2px 8px;background-color:#fafafa;border-bottom:1px solid #ddd;display:flex;flex-wrap:wrap;column-gap:8px;row-gap:2px;align-items:center;min-height:26px}.ht-controls.hidden{display:none}.ht-control-group{display:flex;align-items:center;gap:4px;white-space:nowrap;padding:2px 0;border-bottom:2px solid transparent;transition:border-bottom-color .2s}.ht-control-group.ht-aesthetic-editing{border-bottom-color:#007bff}.ht-control-label{font-family:sans-serif;font-size:14px;color:#333;white-space:nowrap;display:flex;align-items:center}.ht-button{font-size:14px;font-family:sans-serif;background-color:#e0e0e0;border:1px solid #ccc;border-radius:4px;cursor:pointer;transition:background-color .2s;white-space:nowrap}.ht-button:hover{background-color:#d0d0d0}.ht-button:disabled{background-color:#f0f0f0;color:#999;cursor:not-allowed}.ht-button.primary{background-color:#007bff;color:#fff;font-weight:700}.ht-button.primary:hover{background-color:#0056b3}.ht-icon-button{font-size:14px;font-family:sans-serif;background-color:#e0e0e0;border:1px solid #ccc;border-radius:4px;cursor:pointer;transition:background-color .2s;display:flex;align-items:center;justify-content:center;padding:2px}.ht-icon-button:hover{background-color:#d0d0d0}.ht-icon-button svg{display:block}.ht-select{padding:1px 2px;font-size:14px;border:1px solid #ccc;border-radius:4px}.ht-slider{cursor:pointer;width:150px}.ht-toggle{width:50px;background-color:#ccc;border-radius:12px;position:relative;cursor:pointer;transition:background-color .3s;flex-shrink:0}.ht-toggle.active{background-color:#007bff}.ht-toggle-knob{background-color:#fff;border-radius:50%;position:absolute;top:2px;left:2px;transition:left .3s}.ht-toggle.active .ht-toggle-knob{left:calc(100% - 2px);transform:translate(-100%)}.ht-number-input{font-size:14px;border:1px solid #ccc;border-radius:4px;width:80px}.ht-text-input{font-size:14px;border:1px solid #ccc;border-radius:4px;padding:2px 4px;width:100px}.ht-aesthetic-settings{padding:2px 8px;background-color:#f0f0f0;border-bottom:1px solid #ddd;display:flex;flex-wrap:wrap;column-gap:8px;row-gap:2px;align-items:center;max-height:1000px;transition:max-height .3s ease-in-out,padding .3s ease-in-out}.ht-aesthetic-settings.hidden{max-height:0;padding-top:0;padding-bottom:0;border-bottom:none}.ht-color-palette-editor{display:flex;gap:8px;align-items:center}.ht-palette-buttons-container{display:flex;flex-direction:column;gap:2px}.ht-palette-button{width:24px;height:24px;font-size:14px;font-weight:700;background-color:#e0e0e0;border:1px solid #ccc;border-radius:4px;cursor:pointer;transition:background-color .2s}.ht-palette-button:hover{background-color:#d0d0d0}.ht-gradient-container{display:flex;gap:8px}.ht-gradient-column{display:flex;flex-direction:column;gap:4px}.ht-color-squares-container{display:flex;position:relative;height:16px;width:200px}.ht-color-square-wrapper{position:absolute;display:flex;flex-direction:column;align-items:center;transform:translate(-50%)}.ht-color-square{width:12px;height:12px;border:2px solid #333;border-radius:4px;cursor:pointer}.ht-color-square-tick{width:2px;height:8px;background-color:#333}.ht-gradient-box{width:200px;height:24px;border:1px solid #ccc;border-radius:4px;position:relative}.ht-range-slider-container{width:200px;height:12px;position:relative;border:1px solid #ccc;border-radius:4px;background:#f0f0f0;display:flex;align-items:center}.ht-range-handle{position:absolute;width:12px;height:12px;border:2px solid #333;border-radius:4px;cursor:ew-resize;transform:translate(-50%)}.ht-range-handle-indicator{position:absolute;bottom:90%;left:50%;transform:translate(-50%) rotate(180deg);width:0;height:0;border-left:4px solid transparent;border-right:4px solid transparent;border-top:6px solid #333;border-radius:2px;margin-bottom:2px}.ht-null-color-column{display:flex;flex-direction:column;gap:4px}.ht-null-color-square-container{display:flex;position:relative;height:16px;justify-content:center}.ht-null-color-square{width:12px;height:12px;border:2px solid #333;border-radius:4px;cursor:pointer}.ht-null-color-square-tick{width:2px;height:6px;background-color:#333;position:absolute;bottom:-6px;left:50%;transform:translate(-50%)}.ht-null-color-box{width:16px;height:24px;border:1px solid #ccc;border-radius:4px;position:relative}.ht-null-color-reset-container{position:relative;display:flex;align-items:center;justify-content:center}.ht-null-color-reset-indicator{position:absolute;bottom:85%;left:50%;transform:translate(-50%) rotate(180deg);width:0;height:0;border-left:4px solid transparent;border-right:4px solid transparent;border-top:6px solid #333;border-radius:2px;margin-bottom:2px}.ht-null-color-reset{font-size:14px;color:#d00;font-weight:700;line-height:1;-webkit-user-select:none;user-select:none;cursor:pointer}.ht-tree{flex:1 1 auto;min-height:0;position:relative}.ht-tree svg{display:block}.picker_wrapper,.picker_wrapper.popup{z-index:999999!important;position:fixed!important}.picker_wrapper *,.picker_selector,.picker_slider,.picker_editor,.picker_sample,.picker_done,.picker_cancel{pointer-events:auto!important}";
2872
+ const styles = ".ht-widget{color-scheme:light!important;--color-background: #ffffff;--color-text: #333333;--color-border: #dddddd;--color-muted: #666666;--color-accent: #007bff;background-color:var(--color-background);color:var(--color-text);display:flex;flex-direction:column;width:100%;height:100%}.ht-toolbar{flex:0 0 auto;margin-bottom:4px;display:flex;flex-direction:column;position:relative}.ht-toggle-container{position:absolute;top:0;right:0;padding:4px 8px;z-index:10}.ht-control-panel-toggle{background-color:transparent;border:none;cursor:pointer;padding:0 4px;display:flex;align-items:center;gap:4px;transition:opacity .2s}.ht-control-panel-toggle:hover{opacity:.7}.ht-control-panel-toggle .ht-toggle-arrow{transition:transform .3s}.ht-control-panel-toggle.collapsed .ht-toggle-arrow{transform:rotate(180deg)}.ht-control-panel-toggle .ht-hamburger-icon{color:#333}.ht-collapsible-panel{max-height:1000px;overflow:hidden;transition:max-height .3s ease-in-out}.ht-collapsible-panel.ht-panel-collapsed{max-height:0}.ht-widget .ht-tabs{display:flex;gap:20px;padding:4px 8px;background-color:#f5f5f5;border-bottom:2px solid #ddd;-webkit-user-select:none;user-select:none}.ht-widget .ht-tab{cursor:pointer;padding:0 4px;font-family:sans-serif;font-size:12px;color:#333;border-bottom:2px solid transparent;transition:all .2s}.ht-widget .ht-tab:hover{color:#666}.ht-widget .ht-tab.active{color:#000;font-weight:700;border-bottom-color:#007bff}.ht-widget .ht-tab.active:hover{color:#000}.ht-widget .ht-tab.disabled{color:#999;cursor:not-allowed;opacity:.5}.ht-widget .ht-tab.disabled:hover{color:#999}.ht-widget .ht-controls{padding:2px 8px;background-color:#fafafa;border-bottom:1px solid #ddd;display:flex;flex-wrap:wrap;column-gap:8px;row-gap:2px;align-items:center;min-height:22px}.ht-controls.hidden{display:none}.ht-control-group{display:flex;align-items:center;gap:4px;white-space:nowrap;padding:2px 0;border-bottom:2px solid transparent;transition:border-bottom-color .2s}.ht-control-group.ht-aesthetic-editing{border-bottom-color:#007bff}.ht-widget .ht-control-label{font-family:sans-serif;font-size:12px;color:#333;white-space:nowrap;display:flex;align-items:center}.ht-button{font-size:12px;font-family:sans-serif;background-color:#e0e0e0;border:1px solid #ccc;border-radius:4px;cursor:pointer;transition:background-color .2s;white-space:nowrap}.ht-button:hover{background-color:#d0d0d0}.ht-button:disabled{background-color:#f0f0f0;color:#999;cursor:not-allowed}.ht-button.primary{background-color:#007bff;color:#fff;font-weight:700}.ht-button.primary:hover{background-color:#0056b3}.ht-icon-button{font-size:12px;font-family:sans-serif;background-color:#e0e0e0;border:1px solid #ccc;border-radius:4px;cursor:pointer;transition:background-color .2s;display:flex;align-items:center;justify-content:center;padding:2px}.ht-icon-button:hover{background-color:#d0d0d0}.ht-icon-button svg{display:block}.ht-select{padding:1px 2px;font-size:12px;border:1px solid #ccc;border-radius:4px}.ht-slider{cursor:pointer;width:150px}.ht-toggle{width:50px;background-color:#ccc;border-radius:12px;position:relative;cursor:pointer;transition:background-color .3s;flex-shrink:0}.ht-toggle.active{background-color:#007bff}.ht-toggle-knob{background-color:#fff;border-radius:50%;position:absolute;top:2px;left:2px;transition:left .3s}.ht-toggle.active .ht-toggle-knob{left:calc(100% - 2px);transform:translate(-100%)}.ht-number-input{font-size:12px;border:1px solid #ccc;border-radius:4px;width:80px}.ht-text-input{font-size:12px;border:1px solid #ccc;border-radius:4px;padding:2px 4px;width:100px}.ht-widget .ht-aesthetic-settings-content{padding:2px 8px;background-color:#f0f0f0;border-bottom:1px solid #ddd;display:flex;flex-wrap:wrap;column-gap:8px;row-gap:2px;align-items:center;max-height:1000px;overflow:hidden;transition:max-height .3s ease-in-out,padding .3s ease-in-out}.ht-aesthetic-settings.hidden{max-height:0;padding-top:0;padding-bottom:0;border-bottom:none}.ht-color-palette-editor{display:flex;gap:8px;align-items:center}.ht-palette-buttons-container{display:flex;flex-direction:column;gap:2px}.ht-palette-button{width:20px;height:20px;font-size:12px;font-weight:700;background-color:#e0e0e0;border:1px solid #ccc;border-radius:4px;cursor:pointer;transition:background-color .2s}.ht-palette-button:hover{background-color:#d0d0d0}.ht-gradient-container{display:flex;gap:8px}.ht-gradient-column{display:flex;flex-direction:column;gap:4px}.ht-color-squares-container{display:flex;position:relative;height:16px;width:200px}.ht-color-square-wrapper{position:absolute;display:flex;flex-direction:column;align-items:center;transform:translate(-50%)}.ht-color-square{width:12px;height:12px;border:2px solid #333;border-radius:4px;cursor:pointer}.ht-color-square-tick{width:2px;height:8px;background-color:#333}.ht-gradient-box{width:200px;height:20px;border:1px solid #ccc;border-radius:4px;position:relative}.ht-widget .ht-range-slider-container{width:200px;height:12px;position:relative;border:1px solid #ccc;border-radius:4px;background:#f0f0f0;display:flex;align-items:center}.ht-range-handle{position:absolute;width:12px;height:12px;border:2px solid #333;border-radius:4px;cursor:ew-resize;transform:translate(-50%)}.ht-range-handle-indicator{position:absolute;bottom:90%;left:50%;transform:translate(-50%) rotate(180deg);width:0;height:0;border-left:4px solid transparent;border-right:4px solid transparent;border-top:6px solid #333;border-radius:2px;margin-bottom:2px}.ht-null-color-column{display:flex;flex-direction:column;gap:4px}.ht-null-color-square-container{display:flex;position:relative;height:16px;justify-content:center}.ht-null-color-square{width:12px;height:12px;border:2px solid #333;border-radius:4px;cursor:pointer}.ht-null-color-square-tick{width:2px;height:6px;background-color:#333;position:absolute;bottom:-6px;left:50%;transform:translate(-50%)}.ht-null-color-box{width:16px;height:20px;border:1px solid #ccc;border-radius:4px;position:relative}.missing-data-x-container{position:relative;display:flex;align-items:center;justify-content:center}.missing-data-x-container-triangle{position:absolute;bottom:85%;left:50%;transform:translate(-50%) rotate(180deg);width:0;height:0;border-left:4px solid transparent;border-right:4px solid transparent;border-top:6px solid #333;border-radius:2px;margin-bottom:2px}.missing-data-x{font-size:16px;color:#d00;font-weight:700;line-height:1;-webkit-user-select:none;user-select:none;cursor:pointer}.ht-tree{flex:1 1 auto;min-height:0;position:relative}.ht-tree svg{display:block}.picker_wrapper,.picker_wrapper.popup{z-index:999999!important;position:fixed!important}.picker_wrapper *,.picker_selector,.picker_slider,.picker_editor,.picker_sample,.picker_done,.picker_cancel{pointer-events:auto!important}";
2873
2873
  function injectStyles() {
2874
2874
  const styleId = "heat-tree-styles";
2875
2875
  if (!document.getElementById(styleId)) {
@@ -3074,6 +3074,61 @@ class ContainerResizeHandler {
3074
3074
  clearTimeout(this.timeoutId);
3075
3075
  }
3076
3076
  }
3077
+ function isNexusFormat(str) {
3078
+ const firstLine = str.trim().split(/\r?\n/)[0].trim();
3079
+ return /^#NEXUS$/i.test(firstLine);
3080
+ }
3081
+ function applyTranslate(node, translateMap) {
3082
+ if (node.name) {
3083
+ const num = parseInt(node.name, 10);
3084
+ if (!isNaN(num) && translateMap.has(num)) {
3085
+ node.name = translateMap.get(num);
3086
+ }
3087
+ }
3088
+ if (node.children) {
3089
+ node.children.forEach((child) => applyTranslate(child, translateMap));
3090
+ }
3091
+ }
3092
+ function parseTreesBlock(blockContent) {
3093
+ const trees = [];
3094
+ const translateMap = /* @__PURE__ */ new Map();
3095
+ const translateMatch = blockContent.match(/translate\s+([^;]+);/i);
3096
+ if (translateMatch) {
3097
+ const translateContent = translateMatch[1];
3098
+ const pairs = translateContent.split(/,\s*/);
3099
+ pairs.forEach((pair) => {
3100
+ const match2 = pair.trim().match(/^(\d+)\s+(.+)$/);
3101
+ if (match2) {
3102
+ const num = parseInt(match2[1], 10);
3103
+ const name = match2[2].trim();
3104
+ translateMap.set(num, name);
3105
+ }
3106
+ });
3107
+ }
3108
+ const treeRegex = /tree\s*\*?\s*(?:(\S+))?\s*=\s*(?:\[\S+\])?\s*([^;]+);/gi;
3109
+ let match;
3110
+ while ((match = treeRegex.exec(blockContent)) !== null) {
3111
+ let treeName = match[1].replace(/['"]+/g, "") || null;
3112
+ if (treeName.toLowerCase() == "untitled") {
3113
+ treeName = null;
3114
+ }
3115
+ const newickStr = match[2].trim();
3116
+ const treeData = parseNewick(newickStr);
3117
+ applyTranslate(treeData, translateMap);
3118
+ trees.push({ treeName, treeData });
3119
+ }
3120
+ return trees;
3121
+ }
3122
+ function parseNexus(nexusStr) {
3123
+ const trees = [];
3124
+ const treesBlockRegex = /begin\s+trees\s*;([^]*?)end\s*;/gi;
3125
+ let blockMatch;
3126
+ while ((blockMatch = treesBlockRegex.exec(nexusStr)) !== null) {
3127
+ const blockTrees = parseTreesBlock(blockMatch[1]);
3128
+ trees.push(...blockTrees);
3129
+ }
3130
+ return trees;
3131
+ }
3077
3132
  function parseNewick(newickStr) {
3078
3133
  newickStr = newickStr.trim();
3079
3134
  if (newickStr[newickStr.length - 1] === ";") {
@@ -3135,7 +3190,7 @@ function parseTable(tsvStr, valid_ids, sep = " ") {
3135
3190
  const metadata = {};
3136
3191
  for (let j = 0; j < headers.length; j++) {
3137
3192
  const colName = headers[j];
3138
- const value = values[j];
3193
+ const value = values[j] === "" ? void 0 : values[j];
3139
3194
  metadata[colName] = value;
3140
3195
  columnValues.get(colName).push(value);
3141
3196
  }
@@ -3144,7 +3199,7 @@ function parseTable(tsvStr, valid_ids, sep = " ") {
3144
3199
  headers.forEach((col) => {
3145
3200
  const values = columnValues.get(col);
3146
3201
  const numericValues = values.map((v) => parseFloat(v)).filter((v) => !isNaN(v));
3147
- const isContinuous = numericValues.length > 0 && numericValues.length === values.filter((v) => v !== "").length;
3202
+ const isContinuous = numericValues.length > 0 && numericValues.length === values.filter((v) => v !== void 0).length;
3148
3203
  columnTypes.set(col, isContinuous ? "continuous" : "categorical");
3149
3204
  let matchCount = 0;
3150
3205
  for (const value of values) {
@@ -3427,6 +3482,9 @@ class CategoricalColorScale {
3427
3482
  };
3428
3483
  this.frequencyMap = /* @__PURE__ */ new Map();
3429
3484
  for (const category of categoryData) {
3485
+ if (category === null || category === void 0 || category === "") {
3486
+ continue;
3487
+ }
3430
3488
  if (this.frequencyMap.has(category)) {
3431
3489
  this.frequencyMap.set(category, this.frequencyMap.get(category) + 1);
3432
3490
  } else {
@@ -3479,7 +3537,9 @@ class CategoricalColorScale {
3479
3537
  }
3480
3538
  return;
3481
3539
  }
3482
- if (this.categories.length === 1) {
3540
+ if (this.categories.length === 0) {
3541
+ return;
3542
+ } else if (this.categories.length === 1) {
3483
3543
  const color2 = this._getColorAtPosition(this.state.transformMin);
3484
3544
  this.categoryColorMap.set(this.categories[0], color2);
3485
3545
  } else {
@@ -4362,6 +4422,70 @@ var Picker = (function() {
4362
4422
  document.documentElement.firstElementChild.appendChild(style);
4363
4423
  Picker.StyleElement = style;
4364
4424
  }
4425
+ function createControlGroup() {
4426
+ const group = document.createElement("div");
4427
+ group.className = "ht-control-group";
4428
+ return group;
4429
+ }
4430
+ function createLabel(text, height) {
4431
+ const label = document.createElement("label");
4432
+ label.className = "ht-control-label";
4433
+ label.textContent = text;
4434
+ label.style.height = `${height}px`;
4435
+ return label;
4436
+ }
4437
+ function createButton(text, title = "", height) {
4438
+ const button = document.createElement("button");
4439
+ button.className = "ht-button";
4440
+ button.textContent = text;
4441
+ button.title = title;
4442
+ button.style.height = `${height}px`;
4443
+ return button;
4444
+ }
4445
+ function createIconButton(iconSvg, title = "", height) {
4446
+ const button = document.createElement("button");
4447
+ button.className = "ht-icon-button";
4448
+ button.innerHTML = iconSvg;
4449
+ button.title = title;
4450
+ button.style.height = `${height}px`;
4451
+ button.style.width = `${height}px`;
4452
+ return button;
4453
+ }
4454
+ function createSlider(min, max, value, step, height) {
4455
+ const slider = document.createElement("input");
4456
+ slider.type = "range";
4457
+ slider.className = "ht-slider";
4458
+ slider.min = min;
4459
+ slider.max = max;
4460
+ slider.value = value;
4461
+ slider.step = step;
4462
+ slider.style.height = `${height}px`;
4463
+ return slider;
4464
+ }
4465
+ function createToggle(initialState, height) {
4466
+ const toggleHeight = Math.min(24, height - 4);
4467
+ const knobSize = toggleHeight - 4;
4468
+ const toggle = document.createElement("div");
4469
+ toggle.className = initialState ? "ht-toggle active" : "ht-toggle";
4470
+ toggle.style.height = `${toggleHeight}px`;
4471
+ const knob = document.createElement("div");
4472
+ knob.className = "ht-toggle-knob";
4473
+ knob.style.width = `${knobSize}px`;
4474
+ knob.style.height = `${knobSize}px`;
4475
+ toggle.appendChild(knob);
4476
+ return toggle;
4477
+ }
4478
+ function createNumberInput(value, min, max, step, height) {
4479
+ const input = document.createElement("input");
4480
+ input.type = "number";
4481
+ input.className = "ht-number-input";
4482
+ input.value = value;
4483
+ input.min = min;
4484
+ input.max = max;
4485
+ input.step = step;
4486
+ input.style.height = `${height}px`;
4487
+ return input;
4488
+ }
4365
4489
  class Aesthetic extends Subscribable {
4366
4490
  state;
4367
4491
  // Object containing all configuration used to infer the scale
@@ -4369,7 +4493,9 @@ class Aesthetic extends Subscribable {
4369
4493
  // the actual scale instance
4370
4494
  values;
4371
4495
  // Store the original values for scale recreation
4372
- defaultPalette = ["#440154", "#31688e", "#35b779", "#fde724"];
4496
+ defaultPalette = ["#440154", "#365C8D", "#1FA187", "#9FDA3A"];
4497
+ treeState;
4498
+ // Reference to the tree state for updates
4373
4499
  constructor(values, options = {}) {
4374
4500
  super();
4375
4501
  if (!options.scaleType) {
@@ -4379,6 +4505,7 @@ class Aesthetic extends Subscribable {
4379
4505
  throw new Error("default is required");
4380
4506
  }
4381
4507
  this.values = values;
4508
+ this.treeState = options.treeState || null;
4382
4509
  this.state = {
4383
4510
  scaleType: void 0,
4384
4511
  default: void 0,
@@ -4396,7 +4523,8 @@ class Aesthetic extends Subscribable {
4396
4523
  transformMin: 0,
4397
4524
  transformMax: 1,
4398
4525
  transformFn: null,
4399
- nullValue: null,
4526
+ nullValue: "#808080",
4527
+ showNullInLegend: true,
4400
4528
  ...options
4401
4529
  };
4402
4530
  this.updateScale(false);
@@ -4499,16 +4627,105 @@ class Aesthetic extends Subscribable {
4499
4627
  * Create settings widget(s) for this aesthetic
4500
4628
  * @param {Object} options - Configuration options
4501
4629
  * @param {number} options.controlHeight - Height of controls
4630
+ * @param {string} options.columnId - The column ID this aesthetic is mapped to
4502
4631
  * @returns {HTMLElement|null} The settings widget container, or null if no settings available
4503
4632
  */
4504
4633
  createSettingsWidget(options = {}) {
4505
4634
  const {
4506
- controlHeight = 24
4635
+ controlHeight = 20,
4636
+ columnId = null
4507
4637
  } = options;
4638
+ if (!columnId) {
4639
+ const message = document.createElement("div");
4640
+ message.textContent = "Select a metadata column to edit its settings";
4641
+ message.style.padding = "10px";
4642
+ message.style.color = "#666";
4643
+ return message;
4644
+ }
4645
+ const container = document.createElement("div");
4646
+ container.className = "ht-aesthetic-settings-content";
4508
4647
  if (this.state.scaleType === "color") {
4509
- return this.createColorPaletteEditor(controlHeight);
4648
+ const paletteEditor = this.createColorPaletteEditor(controlHeight);
4649
+ if (paletteEditor) {
4650
+ container.appendChild(paletteEditor);
4651
+ }
4652
+ if (this.state.isCategorical) {
4653
+ const maxCategoriesGroup = createControlGroup();
4654
+ const maxCategoriesLabel = createLabel("Max colors:", controlHeight);
4655
+ maxCategoriesGroup.appendChild(maxCategoriesLabel);
4656
+ const maxCategoriesInput = createNumberInput(
4657
+ this.state.maxCategories || 7,
4658
+ 2,
4659
+ 100,
4660
+ 1,
4661
+ controlHeight
4662
+ );
4663
+ maxCategoriesInput.addEventListener("input", (e) => {
4664
+ const value = parseInt(e.target.value);
4665
+ if (isNaN(value) || value < 1) return;
4666
+ this.updateState({ maxCategories: value });
4667
+ this.updateScale(this.values);
4668
+ if (this.treeState) {
4669
+ this.treeState.state.treeData.tree.each((node) => {
4670
+ const columnValue = node[columnId];
4671
+ if (columnValue !== void 0 && columnValue !== null) {
4672
+ node.tipLabelColor = this.getValue(columnValue);
4673
+ }
4674
+ });
4675
+ this.treeState.updateCoordinates();
4676
+ this.treeState.notify("legendsChange");
4677
+ }
4678
+ });
4679
+ maxCategoriesGroup.appendChild(maxCategoriesInput);
4680
+ container.appendChild(maxCategoriesGroup);
4681
+ }
4682
+ const titleGroup = createControlGroup();
4683
+ const titleLabel = createLabel("Legend title:", controlHeight);
4684
+ titleGroup.appendChild(titleLabel);
4685
+ const titleInput = document.createElement("input");
4686
+ titleInput.type = "text";
4687
+ titleInput.className = "ht-text-input";
4688
+ titleInput.style.height = `${controlHeight}px`;
4689
+ titleInput.style.flex = "1";
4690
+ titleInput.value = this.state.title || "";
4691
+ titleInput.placeholder = "Enter legend title";
4692
+ titleInput.addEventListener("input", (e) => {
4693
+ this.updateState({ title: e.target.value });
4694
+ if (this.treeState) {
4695
+ this.treeState.notify("legendsChange");
4696
+ }
4697
+ });
4698
+ titleGroup.appendChild(titleInput);
4699
+ container.appendChild(titleGroup);
4700
+ if (!this.state.isCategorical) {
4701
+ const unitsGroup = createControlGroup();
4702
+ const unitsLabel = createLabel("Units label:", controlHeight);
4703
+ unitsGroup.appendChild(unitsLabel);
4704
+ const unitsInput = document.createElement("input");
4705
+ unitsInput.type = "text";
4706
+ unitsInput.className = "ht-text-input";
4707
+ unitsInput.style.height = `${controlHeight}px`;
4708
+ unitsInput.style.flex = "1";
4709
+ unitsInput.value = this.state.inputUnits || "";
4710
+ unitsInput.placeholder = "Enter units (e.g., °C, km)";
4711
+ unitsInput.addEventListener("input", (e) => {
4712
+ this.updateState({ inputUnits: e.target.value });
4713
+ if (this.treeState) {
4714
+ this.treeState.notify("legendsChange");
4715
+ }
4716
+ });
4717
+ unitsGroup.appendChild(unitsInput);
4718
+ container.appendChild(unitsGroup);
4719
+ }
4510
4720
  }
4511
- return null;
4721
+ if (container.children.length === 0) {
4722
+ const placeholder = document.createElement("div");
4723
+ placeholder.textContent = "No settings available for this aesthetic";
4724
+ placeholder.style.padding = "10px";
4725
+ placeholder.style.color = "#666";
4726
+ container.appendChild(placeholder);
4727
+ }
4728
+ return container;
4512
4729
  }
4513
4730
  /**
4514
4731
  * Create a color palette editor widget
@@ -4733,27 +4950,18 @@ class Aesthetic extends Subscribable {
4733
4950
  nullPickerContainer.style.top = `${rect.bottom + 5}px`;
4734
4951
  nullPickerContainer.style.display = "block";
4735
4952
  });
4736
- const resetContainer = document.createElement("div");
4737
- resetContainer.className = "ht-null-color-reset-container";
4738
- resetContainer.title = "Reset to default missing data color";
4953
+ const missingDataXContainer = document.createElement("div");
4954
+ missingDataXContainer.className = "missing-data-x-container";
4739
4955
  const resetIndicator = document.createElement("div");
4740
- resetIndicator.className = "ht-null-color-reset-indicator";
4741
- const resetX = document.createElement("div");
4742
- resetX.className = "ht-null-color-reset";
4743
- resetX.textContent = "✕";
4744
- resetX.addEventListener("click", () => {
4745
- const defaultNullColor = "#808080";
4746
- this.state.nullValue = defaultNullColor;
4747
- nullSquare.style.backgroundColor = defaultNullColor;
4748
- nullColorBox.style.backgroundColor = defaultNullColor;
4749
- nullColorPicker.setColor(defaultNullColor, true);
4750
- this.updateScale();
4751
- });
4752
- resetContainer.appendChild(resetIndicator);
4753
- resetContainer.appendChild(resetX);
4956
+ resetIndicator.className = "missing-data-x-container-triangle";
4957
+ const missingDataX = document.createElement("div");
4958
+ missingDataX.className = "missing-data-x";
4959
+ missingDataX.textContent = "✕";
4960
+ missingDataXContainer.appendChild(resetIndicator);
4961
+ missingDataXContainer.appendChild(missingDataX);
4754
4962
  nullColorColumn.appendChild(nullColorSquareContainer);
4755
4963
  nullColorColumn.appendChild(nullColorBox);
4756
- nullColorColumn.appendChild(resetContainer);
4964
+ nullColorColumn.appendChild(missingDataXContainer);
4757
4965
  gradientContainer.appendChild(gradientColumn);
4758
4966
  gradientContainer.appendChild(nullColorColumn);
4759
4967
  const leftButtonsContainer = document.createElement("div");
@@ -4909,9 +5117,15 @@ class TreeData extends Subscribable {
4909
5117
  validIdColumns = /* @__PURE__ */ new Map();
4910
5118
  // Map of table ID to array of column names that contain valid node IDs
4911
5119
  #nextTableId = 0;
4912
- constructor(newickStr, metadataTables = [], metadataTableNames = []) {
5120
+ /**
5121
+ * Create TreeData from a parsed tree object
5122
+ * @param {Object} treeDataObj - Parsed tree object from parseNewick or parseNexus
5123
+ * @param {Array} metadataTables - Optional array of metadata table strings
5124
+ * @param {Array} metadataTableNames - Optional array of metadata table names
5125
+ */
5126
+ constructor(treeDataObj, metadataTables = [], metadataTableNames = []) {
4913
5127
  super();
4914
- this.tree = this.parseTree(newickStr);
5128
+ this.tree = this.createHierarchy(treeDataObj);
4915
5129
  if (Array.isArray(metadataTables)) {
4916
5130
  metadataTables.forEach((tableStr, index) => {
4917
5131
  this.addTable(tableStr, metadataTableNames[index]);
@@ -4919,12 +5133,53 @@ class TreeData extends Subscribable {
4919
5133
  }
4920
5134
  }
4921
5135
  /**
4922
- * Parse a Newick string and create a hierarchy
4923
- * @param {string} newickStr - Newick formatted string
5136
+ * Static factory method to create TreeData from tree string (Newick or NEXUS)
5137
+ * @param {string} treeString - Newick or NEXUS formatted string
5138
+ * @param {Array} metadataTables - Optional array of metadata table strings
5139
+ * @param {Array} metadataTableNames - Optional array of metadata table names
5140
+ * @returns {TreeData} New TreeData instance
5141
+ */
5142
+ static fromTreeString(treeString, metadataTables = [], metadataTableNames = []) {
5143
+ const trees = TreeData.parseTrees(treeString, "Tree");
5144
+ if (trees.length !== 1) {
5145
+ throw new Error(`Expected exactly one tree, but found ${trees.length}. Use TreeData.parseTrees() for multiple trees.`);
5146
+ }
5147
+ return new TreeData(trees[0].treeData, metadataTables, metadataTableNames);
5148
+ }
5149
+ /**
5150
+ * Parse tree input (Newick or NEXUS) and return array of trees with naming
5151
+ * @param {string} input - Tree input string
5152
+ * @param {string} sourceName - Base name for the tree(s) (e.g., filename or user-provided)
5153
+ * @returns {Array<{name: string, treeData: Object}>} Array of parsed trees with names
5154
+ */
5155
+ static parseTrees(input, sourceName = "Tree") {
5156
+ let trees;
5157
+ if (isNexusFormat(input)) {
5158
+ trees = parseNexus(input);
5159
+ } else {
5160
+ const treeData = parseNewick(input);
5161
+ trees = [{ treeName: null, treeData }];
5162
+ }
5163
+ const multiple = trees.length > 1;
5164
+ return trees.map((tree, index) => {
5165
+ let name;
5166
+ if (tree.treeName) {
5167
+ name = multiple ? `${sourceName} ${tree.treeName}` : `${sourceName} - ${tree.treeName}`;
5168
+ } else {
5169
+ name = multiple ? `${sourceName} ${index + 1}` : sourceName;
5170
+ }
5171
+ return {
5172
+ name,
5173
+ treeData: tree.treeData
5174
+ };
5175
+ });
5176
+ }
5177
+ /**
5178
+ * Create a D3 hierarchy from parsed tree data
5179
+ * @param {Object} treeData - Parsed tree object from parseNewick
4924
5180
  * @returns {object} D3 hierarchy object
4925
5181
  */
4926
- parseTree(newickStr) {
4927
- const treeData = parseNewick(newickStr);
5182
+ createHierarchy(treeData) {
4928
5183
  const root2 = hierarchy(treeData, (d) => d.children).sum((d) => d.children ? 0 : 1).each(function(d) {
4929
5184
  d.leafCount = d.value;
4930
5185
  delete d.value;
@@ -4938,10 +5193,10 @@ class TreeData extends Subscribable {
4938
5193
  }
4939
5194
  /**
4940
5195
  * Parse and set the tree data
4941
- * @param {string} newickStr - Newick formatted string or path
5196
+ * @param {Object} treeDataObj - Parsed tree object
4942
5197
  */
4943
- setTree(newickStr) {
4944
- this.tree = this.parseTree(newickStr);
5198
+ setTree(treeDataObj) {
5199
+ this.tree = this.createHierarchy(treeDataObj);
4945
5200
  this.metadata.keys().forEach((tableId) => this.#attachTable(tableId));
4946
5201
  this.notify("treeUpdated", this);
4947
5202
  }
@@ -5170,15 +5425,26 @@ class TreeData extends Subscribable {
5170
5425
  }
5171
5426
  let values = [];
5172
5427
  this.tree.each((node) => {
5173
- if (node.metadata && node.metadata[columnId] !== void 0) {
5428
+ if (state.subset == "tips" && node.children) {
5429
+ return;
5430
+ }
5431
+ if (node.metadata) {
5174
5432
  values.push(node.metadata[columnId]);
5433
+ } else {
5434
+ values.push(void 0);
5175
5435
  }
5176
5436
  });
5177
5437
  if (values.length === 0) {
5178
5438
  console.error(`No values found for column ${columnId}`);
5179
5439
  }
5180
5440
  const displayName = this.columnDisplayName.get(columnId) || columnId;
5441
+ let scaleType = state.scaleType;
5442
+ if (!scaleType) {
5443
+ scaleType = "color";
5444
+ }
5181
5445
  const aesthetic = new Aesthetic(values, {
5446
+ scaleType,
5447
+ default: state.default !== void 0 ? state.default : isCategorical ? null : 0,
5182
5448
  isCategorical,
5183
5449
  inputUnits: displayName,
5184
5450
  ...state
@@ -5367,7 +5633,7 @@ function calculateCircularScalingFactors(root2, options) {
5367
5633
  }
5368
5634
  const minLabelScale = Math.min(...leafData.map((a) => a.labelScale));
5369
5635
  const maxBranchX = Math.max(...leafData.map((a) => a.radius));
5370
- const minBranchX = Math.min(...leafData.map((a) => a.radius));
5636
+ const meanBranchX = leafData.reduce((sum, x) => sum + x.radius, 0) / leafData.length;
5371
5637
  const nonZeroBranches = root2.descendants().filter((a) => a.data.length > 0 && a.children);
5372
5638
  const minBranchLength = nonZeroBranches.length > 0 ? Math.min(...nonZeroBranches.map((a) => a.data.length)) : Infinity;
5373
5639
  applyLabelMin(options.minFontPx / minLabelScale);
@@ -5378,6 +5644,15 @@ function calculateCircularScalingFactors(root2, options) {
5378
5644
  ))
5379
5645
  );
5380
5646
  }
5647
+ const totalAnnotationHeight = leafData.reduce((sum, a) => sum + a.height, 0);
5648
+ if (totalAnnotationHeight > 0 && meanBranchX > 0) {
5649
+ if (branchLenToPxFactor_min !== branchLenToPxFactor_max) {
5650
+ applyBranchMin(totalAnnotationHeight * labelSizeToPxFactor_min / (meanBranchX * 2 * Math.PI));
5651
+ }
5652
+ if (labelSizeToPxFactor_min !== labelSizeToPxFactor_max) {
5653
+ applyLabelMax(maxBranchX * branchLenToPxFactor_max * 2 * Math.PI / totalAnnotationHeight);
5654
+ }
5655
+ }
5381
5656
  function applyBranchViewConstraint() {
5382
5657
  const rightBranchFactor = Math.min(...leafData.filter((a) => a.cos > 0).map((a) => (options.viewWidth / 2 - a.width * a.cos * labelSizeToPxFactor_min) / (a.radius * a.cos)));
5383
5658
  const leftBranchFactor = Math.min(...leafData.filter((a) => a.cos < 0).map((a) => (options.viewWidth / 2 - a.width * -a.cos * labelSizeToPxFactor_min) / (a.radius * -a.cos)));
@@ -5400,18 +5675,6 @@ function calculateCircularScalingFactors(root2, options) {
5400
5675
  }
5401
5676
  applyBranchViewConstraint();
5402
5677
  applyLabelViewConstraint();
5403
- const totalAnnotationHeight = leafData.reduce((sum, a) => sum + a.height, 0);
5404
- if (totalAnnotationHeight > 0 && minBranchX > 0) {
5405
- if (branchLenToPxFactor_min !== branchLenToPxFactor_max) {
5406
- applyBranchMin(totalAnnotationHeight * labelSizeToPxFactor_min / (minBranchX * 2 * Math.PI));
5407
- }
5408
- if (labelSizeToPxFactor_min !== labelSizeToPxFactor_max) {
5409
- applyLabelMax(maxBranchX * branchLenToPxFactor_max * 2 * Math.PI / totalAnnotationHeight);
5410
- }
5411
- }
5412
- if (labelSizeToPxFactor_min !== labelSizeToPxFactor_max) {
5413
- applyLabelViewConstraint();
5414
- }
5415
5678
  if (labelSizeToPxFactor_min !== labelSizeToPxFactor_max) {
5416
5679
  applyLabelMin(options.idealFontPx / minLabelScale);
5417
5680
  }
@@ -5511,7 +5774,8 @@ class TreeState extends Subscribable {
5511
5774
  default: "",
5512
5775
  nullValue: "",
5513
5776
  downstream: ["updateTipLabelText", "updateCoordinates"],
5514
- hasLegend: false
5777
+ hasLegend: false,
5778
+ subset: "tips"
5515
5779
  },
5516
5780
  tipLabelColor: {
5517
5781
  title: "Tip label color",
@@ -5520,7 +5784,8 @@ class TreeState extends Subscribable {
5520
5784
  nullValue: "#808080",
5521
5785
  otherCategory: "#555555",
5522
5786
  downstream: [],
5523
- hasLegend: true
5787
+ hasLegend: true,
5788
+ subset: "tips"
5524
5789
  },
5525
5790
  tipLabelSize: {
5526
5791
  title: "Tip label size",
@@ -5530,7 +5795,8 @@ class TreeState extends Subscribable {
5530
5795
  isCategorical: false,
5531
5796
  outputRange: [0.5, 2],
5532
5797
  downstream: ["updateCoordinates"],
5533
- hasLegend: true
5798
+ hasLegend: true,
5799
+ subset: "tips"
5534
5800
  },
5535
5801
  tipLabelFont: {
5536
5802
  title: "Tip label font",
@@ -5538,7 +5804,8 @@ class TreeState extends Subscribable {
5538
5804
  default: "sans-serif",
5539
5805
  nullValue: "sans-serif",
5540
5806
  downstream: ["updateCoordinates"],
5541
- hasLegend: false
5807
+ hasLegend: false,
5808
+ subset: "tips"
5542
5809
  },
5543
5810
  tipLabelStyle: {
5544
5811
  title: "Tip label font style",
@@ -5548,7 +5815,8 @@ class TreeState extends Subscribable {
5548
5815
  nullValue: "normal",
5549
5816
  otherCategory: "italic",
5550
5817
  downstream: ["updateCoordinates"],
5551
- hasLegend: false
5818
+ hasLegend: false,
5819
+ subset: "tips"
5552
5820
  },
5553
5821
  nodeLabelText: {
5554
5822
  title: "Node label text",
@@ -5556,7 +5824,8 @@ class TreeState extends Subscribable {
5556
5824
  default: "",
5557
5825
  nullValue: "",
5558
5826
  downstream: ["updateNodeLabelText"],
5559
- hasLegend: false
5827
+ hasLegend: false,
5828
+ subset: "all"
5560
5829
  },
5561
5830
  nodeLabelSize: {
5562
5831
  title: "Node label size",
@@ -5566,7 +5835,8 @@ class TreeState extends Subscribable {
5566
5835
  isCategorical: false,
5567
5836
  outputRange: [0.5, 2],
5568
5837
  downstream: ["updateCoordinates"],
5569
- hasLegend: false
5838
+ hasLegend: false,
5839
+ subset: "all"
5570
5840
  }
5571
5841
  };
5572
5842
  state = {
@@ -6396,9 +6666,11 @@ class TextColorLegend extends LegendBase {
6396
6666
  let currentX = 0;
6397
6667
  let currentY = titleHeightOffset + this.verticalSpacing + this.squareSize / 2;
6398
6668
  let rowHeight = this.squareSize;
6399
- categories.slice(0, aesthetic.state.maxCategories).forEach((category, i) => {
6669
+ const categoriesToShow = categories.slice(0, aesthetic.state.maxCategories);
6670
+ categoriesToShow.forEach((category, i) => {
6400
6671
  const color2 = aesthetic.scale.getValue(category);
6401
- const labelSize = this.textSizeEstimator.getTextSize(category, this.state.labelFontSize);
6672
+ const labelText = category === "" ? "No data" : category;
6673
+ const labelSize = this.textSizeEstimator.getTextSize(labelText, this.state.labelFontSize);
6402
6674
  const itemWidth = this.squareSize + this.itemLabelGap + labelSize.widthPx;
6403
6675
  if (currentX > 0 && currentX + itemWidth > maxWidth) {
6404
6676
  currentX = 0;
@@ -6409,7 +6681,7 @@ class TextColorLegend extends LegendBase {
6409
6681
  x: currentX,
6410
6682
  y: currentY,
6411
6683
  color: color2,
6412
- label: i < aesthetic.state.maxCategories - 1 ? category : aesthetic.state.otherLabel,
6684
+ label: i < aesthetic.state.maxCategories - 1 || i == categories.length - 1 ? labelText : aesthetic.state.otherLabel,
6413
6685
  squareX: currentX,
6414
6686
  squareY: currentY - this.squareSize / 2,
6415
6687
  labelX: currentX + this.squareSize + this.itemLabelGap,
@@ -6418,6 +6690,30 @@ class TextColorLegend extends LegendBase {
6418
6690
  currentX += itemWidth + this.itemGap;
6419
6691
  this.coordinates.width = Math.max(this.coordinates.width, currentX - this.itemGap);
6420
6692
  });
6693
+ const hasNullValues = aesthetic.values.some((x) => x == void 0);
6694
+ if (aesthetic.state.showNullInLegend && hasNullValues) {
6695
+ const color2 = aesthetic.state.nullValue;
6696
+ const labelText = "No Data";
6697
+ const labelSize = this.textSizeEstimator.getTextSize(labelText, this.state.labelFontSize);
6698
+ const itemWidth = this.squareSize + this.itemLabelGap + labelSize.widthPx;
6699
+ if (currentX > 0 && currentX + itemWidth > maxWidth) {
6700
+ currentX = 0;
6701
+ currentY += rowHeight + this.verticalSpacing;
6702
+ rowHeight = this.squareSize;
6703
+ }
6704
+ this.coordinates.items.push({
6705
+ x: currentX,
6706
+ y: currentY,
6707
+ color: color2,
6708
+ label: labelText,
6709
+ squareX: currentX,
6710
+ squareY: currentY - this.squareSize / 2,
6711
+ labelX: currentX + this.squareSize + this.itemLabelGap,
6712
+ labelY: currentY
6713
+ });
6714
+ currentX += itemWidth + this.itemGap;
6715
+ this.coordinates.width = Math.max(this.coordinates.width, currentX - this.itemGap);
6716
+ }
6421
6717
  this.coordinates.height = currentY + this.squareSize / 2;
6422
6718
  }
6423
6719
  /**
@@ -6441,7 +6737,7 @@ class TextColorLegend extends LegendBase {
6441
6737
  const ticksY = gradientY + this.gradientHeight;
6442
6738
  const labelsY = ticksY + this.tickHeight;
6443
6739
  const unitsSize = this.textSizeEstimator.getTextSize(aesthetic.state.inputUnits || "", this.state.labelFontSize);
6444
- const height = labelsY + this.state.labelFontSize + unitsSize.heightPx;
6740
+ let height = labelsY + this.state.labelFontSize + unitsSize.heightPx;
6445
6741
  this.coordinates = {
6446
6742
  width,
6447
6743
  height,
@@ -6464,7 +6760,8 @@ class TextColorLegend extends LegendBase {
6464
6760
  x: width / 2,
6465
6761
  y: height,
6466
6762
  text: aesthetic.state.inputUnits || ""
6467
- }
6763
+ },
6764
+ nullItem: null
6468
6765
  };
6469
6766
  if (minValue === maxValue) {
6470
6767
  const x = leftOverhang + baseWidth / 2;
@@ -6495,6 +6792,23 @@ class TextColorLegend extends LegendBase {
6495
6792
  });
6496
6793
  });
6497
6794
  }
6795
+ if (aesthetic.state.showNullInLegend) {
6796
+ const color2 = aesthetic.state.nullValue;
6797
+ const labelText = "No Data";
6798
+ this.textSizeEstimator.getTextSize(labelText, this.state.labelFontSize);
6799
+ const nullItemY = height + this.verticalSpacing;
6800
+ this.coordinates.nullItem = {
6801
+ x: leftOverhang,
6802
+ y: nullItemY + this.squareSize / 2,
6803
+ color: color2,
6804
+ label: labelText,
6805
+ squareX: leftOverhang,
6806
+ squareY: nullItemY,
6807
+ labelX: leftOverhang + this.squareSize + this.itemLabelGap,
6808
+ labelY: nullItemY + this.squareSize / 2
6809
+ };
6810
+ this.coordinates.height = nullItemY + this.squareSize;
6811
+ }
6498
6812
  }
6499
6813
  /**
6500
6814
  * Render the legend in the specified SVG element
@@ -7646,72 +7960,8 @@ function convertSvgToPng(svgString, width, height, filename) {
7646
7960
  };
7647
7961
  img.src = url;
7648
7962
  }
7649
- function createControlGroup() {
7650
- const group = document.createElement("div");
7651
- group.className = "ht-control-group";
7652
- return group;
7653
- }
7654
- function createLabel(text, height) {
7655
- const label = document.createElement("label");
7656
- label.className = "ht-control-label";
7657
- label.textContent = text;
7658
- label.style.height = `${height}px`;
7659
- return label;
7660
- }
7661
- function createButton(text, title = "", height) {
7662
- const button = document.createElement("button");
7663
- button.className = "ht-button";
7664
- button.textContent = text;
7665
- button.title = title;
7666
- button.style.height = `${height}px`;
7667
- return button;
7668
- }
7669
- function createIconButton(iconSvg, title = "", height) {
7670
- const button = document.createElement("button");
7671
- button.className = "ht-icon-button";
7672
- button.innerHTML = iconSvg;
7673
- button.title = title;
7674
- button.style.height = `${height}px`;
7675
- button.style.width = `${height}px`;
7676
- return button;
7677
- }
7678
- function createSlider(min, max, value, step, height) {
7679
- const slider = document.createElement("input");
7680
- slider.type = "range";
7681
- slider.className = "ht-slider";
7682
- slider.min = min;
7683
- slider.max = max;
7684
- slider.value = value;
7685
- slider.step = step;
7686
- slider.style.height = `${height}px`;
7687
- return slider;
7688
- }
7689
- function createToggle(initialState, height) {
7690
- const toggleHeight = Math.min(24, height - 4);
7691
- const knobSize = toggleHeight - 4;
7692
- const toggle = document.createElement("div");
7693
- toggle.className = initialState ? "ht-toggle active" : "ht-toggle";
7694
- toggle.style.height = `${toggleHeight}px`;
7695
- const knob = document.createElement("div");
7696
- knob.className = "ht-toggle-knob";
7697
- knob.style.width = `${knobSize}px`;
7698
- knob.style.height = `${knobSize}px`;
7699
- toggle.appendChild(knob);
7700
- return toggle;
7701
- }
7702
- function createNumberInput(value, min, max, step, height) {
7703
- const input = document.createElement("input");
7704
- input.type = "number";
7705
- input.className = "ht-number-input";
7706
- input.value = value;
7707
- input.min = min;
7708
- input.max = max;
7709
- input.step = step;
7710
- input.style.height = `${height}px`;
7711
- return input;
7712
- }
7713
7963
  function createToolbar(toolbarDiv, treeDataInstances, getCurrentTreeState, getCurrentTreeView, switchToTree, addNewTree, options) {
7714
- const CONTROL_HEIGHT = 24;
7964
+ const CONTROL_HEIGHT = 20;
7715
7965
  let currentTab = null;
7716
7966
  let selectedMetadata = null;
7717
7967
  let currentAestheticSettings = null;
@@ -7795,12 +8045,15 @@ function createToolbar(toolbarDiv, treeDataInstances, getCurrentTreeState, getCu
7795
8045
  }
7796
8046
  }
7797
8047
  }
7798
- toggleButton.addEventListener("click", () => {
8048
+ toggleButton.addEventListener("click", (e) => {
8049
+ e.preventDefault();
8050
+ e.stopPropagation();
7799
8051
  controlPanelVisible = !controlPanelVisible;
7800
8052
  if (controlPanelVisible) {
7801
8053
  collapsiblePanel.classList.remove("ht-panel-collapsed");
7802
8054
  toggleButton.classList.remove("collapsed");
7803
8055
  } else {
8056
+ closeAestheticSettings();
7804
8057
  collapsiblePanel.classList.add("ht-panel-collapsed");
7805
8058
  toggleButton.classList.add("collapsed");
7806
8059
  }
@@ -7889,14 +8142,23 @@ function createToolbar(toolbarDiv, treeDataInstances, getCurrentTreeState, getCu
7889
8142
  aestheticSettingsContainer.innerHTML = "";
7890
8143
  const treeState = getCurrentTreeState();
7891
8144
  if (!treeState) return;
7892
- if (aestheticId === "tipLabelColor") {
7893
- populateTipLabelColorSettings(aestheticSettingsContainer, treeState, CONTROL_HEIGHT);
8145
+ const columnId = treeState.state.aesthetics[aestheticId];
8146
+ const aesthetic = treeState.aestheticsScales[aestheticId];
8147
+ if (!aesthetic) {
8148
+ const message = document.createElement("div");
8149
+ message.textContent = "Error: Could not find aesthetic";
8150
+ message.style.padding = "10px";
8151
+ message.style.color = "#d00";
8152
+ aestheticSettingsContainer.appendChild(message);
7894
8153
  return;
7895
8154
  }
7896
- const placeholder = document.createElement("div");
7897
- placeholder.textContent = `Settings for ${aestheticId} (coming soon)`;
7898
- placeholder.style.padding = "10px";
7899
- aestheticSettingsContainer.appendChild(placeholder);
8155
+ const settingsWidget = aesthetic.createSettingsWidget({
8156
+ controlHeight: CONTROL_HEIGHT,
8157
+ columnId
8158
+ });
8159
+ if (settingsWidget) {
8160
+ aestheticSettingsContainer.appendChild(settingsWidget);
8161
+ }
7900
8162
  }
7901
8163
  function openTab(tabId) {
7902
8164
  const tabDef = tabs.find((t) => t.id === tabId);
@@ -8012,14 +8274,13 @@ function createToolbar(toolbarDiv, treeDataInstances, getCurrentTreeState, getCu
8012
8274
  toolbarDiv.appendChild(toggleContainer);
8013
8275
  toolbarDiv.appendChild(collapsiblePanel);
8014
8276
  updateTabStates();
8015
- openTab(tabs[0].id);
8016
8277
  resetSelectedMetadata();
8017
8278
  return refreshCurrentTab;
8018
8279
  }
8019
8280
  function populateDataControls(container, treeDataInstances, getCurrentTreeState, switchToTree, addNewTree, getCurrentMetadataNames, getSelectedMetadata, setSelectedMetadata, resetSelectedMetadata, refreshCurrentTab, options, controlHeight) {
8020
8281
  container.innerHTML = "";
8021
8282
  const treeGroup = createControlGroup();
8022
- const treeLabel = createLabel("Select tree:", controlHeight);
8283
+ const treeLabel = createLabel("Tree:", controlHeight);
8023
8284
  treeGroup.appendChild(treeLabel);
8024
8285
  const treeSelect = document.createElement("select");
8025
8286
  treeSelect.className = "ht-select";
@@ -8051,24 +8312,19 @@ function populateDataControls(container, treeDataInstances, getCurrentTreeState,
8051
8312
  container.appendChild(treeGroup);
8052
8313
  const treeFileInput = document.createElement("input");
8053
8314
  treeFileInput.type = "file";
8054
- treeFileInput.accept = ".nwk,.newick,.tree,.tre,.treefile";
8055
8315
  treeFileInput.style.display = "none";
8056
8316
  treeFileInput.addEventListener("change", async (e) => {
8057
8317
  const file = e.target.files[0];
8058
8318
  if (!file) return;
8059
8319
  try {
8060
- const newickStr = await file.text();
8061
- let treeName = file.name.replace(/\.(nwk|newick|tree|tre)$/i, "");
8062
- let uniqueName = treeName;
8063
- let counter = 1;
8064
- while (treeDataInstances.has(uniqueName)) {
8065
- uniqueName = `${treeName} (${counter})`;
8066
- counter++;
8067
- }
8068
- addNewTree(uniqueName, newickStr);
8320
+ const treeString = await file.text();
8321
+ let treeName = file.name.replace(/\.[^/.]+$/, "");
8322
+ const addedNames = addNewTree(treeName, treeString);
8069
8323
  treeFileInput.value = "";
8070
8324
  refreshCurrentTab();
8071
- switchToTree(uniqueName);
8325
+ if (addedNames.length > 0) {
8326
+ switchToTree(addedNames[0]);
8327
+ }
8072
8328
  } catch (error) {
8073
8329
  console.error("Error loading tree file:", error);
8074
8330
  alert(`Error loading tree file: ${error.message}`);
@@ -8084,7 +8340,7 @@ function populateDataControls(container, treeDataInstances, getCurrentTreeState,
8084
8340
  return;
8085
8341
  }
8086
8342
  const metadataGroup = createControlGroup();
8087
- const metadataLabel = createLabel("Available metadata:", controlHeight);
8343
+ const metadataLabel = createLabel("Metadata:", controlHeight);
8088
8344
  metadataGroup.appendChild(metadataLabel);
8089
8345
  const metadataSelect = document.createElement("select");
8090
8346
  metadataSelect.className = "ht-select";
@@ -8169,7 +8425,7 @@ function populateDataControls(container, treeDataInstances, getCurrentTreeState,
8169
8425
  const validIdColumns = treeData.getValidIdColumns(selectedTableId);
8170
8426
  const currentIdColumn = treeData.getNodeIdColumn(selectedTableId);
8171
8427
  const nodeIdGroup = createControlGroup();
8172
- const nodeIdLabel = createLabel("Node/Tip ID Column:", controlHeight);
8428
+ const nodeIdLabel = createLabel("ID Column:", controlHeight);
8173
8429
  nodeIdGroup.appendChild(nodeIdLabel);
8174
8430
  const nodeIdSelect = document.createElement("select");
8175
8431
  nodeIdSelect.className = "ht-select";
@@ -8184,7 +8440,7 @@ function populateDataControls(container, treeDataInstances, getCurrentTreeState,
8184
8440
  validIdColumns.forEach((columnName) => {
8185
8441
  const option = document.createElement("option");
8186
8442
  option.value = columnName;
8187
- option.textContent = treeData.columnDisplayName.get(columnName);
8443
+ option.textContent = treeData.columnName.get(columnName);
8188
8444
  if (columnName === currentIdColumn) {
8189
8445
  option.selected = true;
8190
8446
  }
@@ -8506,101 +8762,6 @@ function populateTipLabelSettingsControls(container, getCurrentTreeState, option
8506
8762
  tipLabelFontGroup.appendChild(tipLabelFontSelect);
8507
8763
  container.appendChild(tipLabelFontGroup);
8508
8764
  }
8509
- function populateTipLabelColorSettings(container, treeState, controlHeight) {
8510
- const columnId = treeState.state.aesthetics.tipLabelColor;
8511
- if (!columnId) {
8512
- const message = document.createElement("div");
8513
- message.textContent = "Select a metadata column for tip label color to edit its settings";
8514
- message.style.padding = "10px";
8515
- message.style.color = "#666";
8516
- container.appendChild(message);
8517
- return;
8518
- }
8519
- const aesthetic = treeState.aestheticsScales.tipLabelColor;
8520
- if (!aesthetic) {
8521
- const message = document.createElement("div");
8522
- message.textContent = "Error: Could not find color aesthetic";
8523
- message.style.padding = "10px";
8524
- message.style.color = "#d00";
8525
- container.appendChild(message);
8526
- return;
8527
- }
8528
- const settingsWidget = aesthetic.createSettingsWidget({
8529
- controlHeight
8530
- });
8531
- if (settingsWidget) {
8532
- container.appendChild(settingsWidget);
8533
- } else {
8534
- const message = document.createElement("div");
8535
- message.textContent = "No settings available for this aesthetic";
8536
- message.style.padding = "10px";
8537
- message.style.color = "#666";
8538
- container.appendChild(message);
8539
- return;
8540
- }
8541
- if (aesthetic.state.isCategorical) {
8542
- const maxCategoriesGroup = createControlGroup();
8543
- const maxCategoriesLabel = createLabel("Max colors:", controlHeight);
8544
- maxCategoriesGroup.appendChild(maxCategoriesLabel);
8545
- const maxCategoriesInput = createNumberInput(
8546
- aesthetic.state.maxCategories || 7,
8547
- 1,
8548
- 100,
8549
- 1,
8550
- controlHeight
8551
- );
8552
- maxCategoriesInput.addEventListener("input", (e) => {
8553
- const value = parseInt(e.target.value);
8554
- if (isNaN(value) || value < 1) return;
8555
- aesthetic.updateState({ maxCategories: value });
8556
- aesthetic.updateScale(aesthetic.values);
8557
- treeState.state.treeData.tree.each((node) => {
8558
- const columnValue = node[columnId];
8559
- if (columnValue !== void 0 && columnValue !== null) {
8560
- node.tipLabelColor = aesthetic.getValue(columnValue);
8561
- }
8562
- });
8563
- treeState.updateCoordinates();
8564
- treeState.notify("legendsChange");
8565
- });
8566
- maxCategoriesGroup.appendChild(maxCategoriesInput);
8567
- container.appendChild(maxCategoriesGroup);
8568
- }
8569
- const titleGroup = createControlGroup();
8570
- const titleLabel = createLabel("Legend title:", controlHeight);
8571
- titleGroup.appendChild(titleLabel);
8572
- const titleInput = document.createElement("input");
8573
- titleInput.type = "text";
8574
- titleInput.className = "ht-text-input";
8575
- titleInput.style.height = `${controlHeight}px`;
8576
- titleInput.style.flex = "1";
8577
- titleInput.value = aesthetic.state.title || "";
8578
- titleInput.placeholder = "Enter legend title";
8579
- titleInput.addEventListener("input", (e) => {
8580
- aesthetic.updateState({ title: e.target.value });
8581
- treeState.notify("legendsChange");
8582
- });
8583
- titleGroup.appendChild(titleInput);
8584
- container.appendChild(titleGroup);
8585
- if (!aesthetic.state.isCategorical) {
8586
- const unitsGroup = createControlGroup();
8587
- const unitsLabel = createLabel("Units label:", controlHeight);
8588
- unitsGroup.appendChild(unitsLabel);
8589
- const unitsInput = document.createElement("input");
8590
- unitsInput.type = "text";
8591
- unitsInput.className = "ht-text-input";
8592
- unitsInput.style.height = `${controlHeight}px`;
8593
- unitsInput.style.flex = "1";
8594
- unitsInput.value = aesthetic.state.inputUnits || "";
8595
- unitsInput.placeholder = "Enter units (e.g., °C, km)";
8596
- unitsInput.addEventListener("input", (e) => {
8597
- aesthetic.updateState({ inputUnits: e.target.value });
8598
- treeState.notify("legendsChange");
8599
- });
8600
- unitsGroup.appendChild(unitsInput);
8601
- container.appendChild(unitsGroup);
8602
- }
8603
- }
8604
8765
  function createMetadataColumnSelect(treeState, aesthetic, defaultLabel, controlHeight, includeNone = false, continuous = null, getCurrentAestheticSettings = null, populateAestheticSettings = null) {
8605
8766
  const select2 = document.createElement("select");
8606
8767
  select2.className = "ht-select";
@@ -8832,10 +8993,10 @@ function heatTree(containerSelector, treesInput = [], options = {}) {
8832
8993
  const treeDataInstances = /* @__PURE__ */ new Map();
8833
8994
  const treeConfigAesthetics = /* @__PURE__ */ new Map();
8834
8995
  treesInput.forEach((treeConfig, index) => {
8835
- if (!treeConfig.newick) {
8836
- throw new Error(`Tree at index ${index} is missing newick string`);
8996
+ if (!treeConfig.tree) {
8997
+ throw new Error(`Tree at index ${index} is missing tree string`);
8837
8998
  }
8838
- const treeName = treeConfig.name || `Tree ${index + 1}`;
8999
+ const sourceName = treeConfig.name || `Tree ${index + 1}`;
8839
9000
  let metadataTables = [];
8840
9001
  let metadataNames = [];
8841
9002
  if (treeConfig.metadata) {
@@ -8850,24 +9011,33 @@ function heatTree(containerSelector, treesInput = [], options = {}) {
8850
9011
  }
8851
9012
  });
8852
9013
  }
8853
- const treeData = new TreeData(treeConfig.newick, metadataTables, metadataNames);
8854
- let treeAesthetics;
8855
- if (treeConfig.aesthetics) {
8856
- treeAesthetics = Object.fromEntries(
8857
- Object.entries(treeConfig.aesthetics).map(([aes, col]) => {
8858
- for (const [assignedColId, originalName] of treeData.columnName.entries()) {
8859
- if (originalName === col) {
8860
- return [aes, assignedColId];
9014
+ const parsedTrees = TreeData.parseTrees(treeConfig.tree, sourceName);
9015
+ parsedTrees.forEach(({ name: parsedName, treeData: parsedTreeData }, treeIndex) => {
9016
+ let uniqueName = parsedName;
9017
+ let counter = 1;
9018
+ while (treeDataInstances.has(uniqueName)) {
9019
+ uniqueName = `${parsedName} (${counter})`;
9020
+ counter++;
9021
+ }
9022
+ const treeData = new TreeData(parsedTreeData, metadataTables, metadataNames);
9023
+ let treeAesthetics;
9024
+ if (treeConfig.aesthetics) {
9025
+ treeAesthetics = Object.fromEntries(
9026
+ Object.entries(treeConfig.aesthetics).map(([aes, col]) => {
9027
+ for (const [assignedColId, originalName] of treeData.columnName.entries()) {
9028
+ if (originalName === col) {
9029
+ return [aes, assignedColId];
9030
+ }
8861
9031
  }
8862
- }
8863
- return void 0;
8864
- })
8865
- );
8866
- } else {
8867
- treeAesthetics = void 0;
8868
- }
8869
- treeDataInstances.set(treeName, treeData);
8870
- treeConfigAesthetics.set(treeName, treeAesthetics);
9032
+ return void 0;
9033
+ }).filter((entry) => entry !== void 0)
9034
+ );
9035
+ } else {
9036
+ treeAesthetics = void 0;
9037
+ }
9038
+ treeDataInstances.set(uniqueName, treeData);
9039
+ treeConfigAesthetics.set(uniqueName, treeAesthetics);
9040
+ });
8871
9041
  });
8872
9042
  const treeStateCache = /* @__PURE__ */ new Map();
8873
9043
  const treeViewCache = /* @__PURE__ */ new Map();
@@ -8904,16 +9074,21 @@ function heatTree(containerSelector, treesInput = [], options = {}) {
8904
9074
  immediate: true
8905
9075
  }
8906
9076
  );
8907
- function addNewTree(treeName, newickStr, metadataTables = [], metadataNames = []) {
8908
- let uniqueName = treeName;
8909
- let counter = 1;
8910
- while (treeDataInstances.has(uniqueName)) {
8911
- uniqueName = `${treeName} (${counter})`;
8912
- counter++;
8913
- }
8914
- const treeData = new TreeData(newickStr, metadataTables, metadataNames);
8915
- treeDataInstances.set(uniqueName, treeData);
8916
- return uniqueName;
9077
+ function addNewTree(treeName, treeString, metadataTables = [], metadataNames = []) {
9078
+ const parsedTrees = TreeData.parseTrees(treeString, treeName);
9079
+ const addedNames = [];
9080
+ parsedTrees.forEach(({ name: parsedName, treeData: parsedTreeData }) => {
9081
+ let uniqueName = parsedName;
9082
+ let counter = 1;
9083
+ while (treeDataInstances.has(uniqueName)) {
9084
+ uniqueName = `${parsedName} (${counter})`;
9085
+ counter++;
9086
+ }
9087
+ const treeData = new TreeData(parsedTreeData, metadataTables, metadataNames);
9088
+ treeDataInstances.set(uniqueName, treeData);
9089
+ addedNames.push(uniqueName);
9090
+ });
9091
+ return addedNames;
8917
9092
  }
8918
9093
  function switchToTree(treeName) {
8919
9094
  if (!treeDataInstances.has(treeName)) {