diff --git a/CONFIG.ts b/CONFIG.ts
index 51fa4833..472374ea 100644
--- a/CONFIG.ts
+++ b/CONFIG.ts
@@ -11,7 +11,10 @@ const config = {
// When opening the viewer, or refreshing the page, the viewer will revert to the following default dataset
data:{
// Default dataset URL (must be publically accessible)
- default_dataset: "https://public.czbiohub.org/royerlab/zoo/Zebrafish/tracks_zebrafish_bundle.zarr/"
+ // default_dataset: "https://public.czbiohub.org/royerlab/zoo/Zebrafish/tracks_zebrafish_bundle.zarr/"
+ // default_dataset: "https://public.czbiohub.org/royerlab/zoo/misc/tracks_ascidian_withSize_withFeatures_bundle.zarr/"
+ // default_dataset: "https://public.czbiohub.org/royerlab/zoo/misc/tracks_zebrafish_smooth_displ_bundle.zarr/"
+ default_dataset: "https://public.czbiohub.org/royerlab/zoo/misc/tracks_drosophila_smooth_displ_bundle.zarr/"
},
// Default settings for certain parameters
@@ -19,8 +22,15 @@ const config = {
// Maximum number of cells a user can select without getting a warning
max_num_selected_cells: 100,
- // Choose colormap for the tracks, options: viridis-inferno, magma-inferno, inferno-inferno, plasma-inferno, cividis-inferno [default]
+ // Choose colormap for the tracks
+ // options: viridis-inferno, magma-inferno, inferno-inferno, plasma-inferno, cividis-inferno [default]
colormap_tracks: "cividis-inferno",
+
+ // Choose colormap for coloring the cells, when the attribute is continuous or categorical
+ // options: HSL, viridis, plasma, inferno, magma, cividis
+ colormap_colorby_categorical: "HSL",
+ colormap_colorby_continuous: "plasma",
+
// Point size (arbitrary units), if cell sizes not provided in zarr attributes
point_size: 0.1,
diff --git a/python/src/intracktive/_tests/test_convert.py b/python/src/intracktive/_tests/test_convert.py
index 7a936aa4..dfe4a11d 100644
--- a/python/src/intracktive/_tests/test_convert.py
+++ b/python/src/intracktive/_tests/test_convert.py
@@ -43,7 +43,8 @@ def test_actual_zarr_content(tmp_path: Path, make_sample_data: pd.DataFrame) ->
convert_dataframe_to_zarr(
df=df,
zarr_path=new_path,
- extra_cols=["radius"],
+ add_radius=True,
+ extra_cols=(),
)
new_data = zarr.open(new_path)
diff --git a/python/src/intracktive/convert.py b/python/src/intracktive/convert.py
index d17976f7..c452a5c7 100644
--- a/python/src/intracktive/convert.py
+++ b/python/src/intracktive/convert.py
@@ -85,7 +85,9 @@ def get_unique_zarr_path(zarr_path: Path) -> Path:
def convert_dataframe_to_zarr(
df: pd.DataFrame,
zarr_path: Path,
+ add_radius: bool = False,
extra_cols: Iterable[str] = (),
+ pre_normalized: bool = False,
) -> Path:
"""
Convert a DataFrame of tracks to a sparse Zarr store
@@ -113,11 +115,18 @@ def convert_dataframe_to_zarr(
flag_2D = True
df["z"] = 0.0
+ points_cols = (
+ ["z", "y", "x", "radius"] if add_radius else ["z", "y", "x"]
+ ) # columns to store in the points array
extra_cols = list(extra_cols)
- columns = REQUIRED_COLUMNS + extra_cols
- points_cols = ["z", "y", "x"] + extra_cols # columns to store in the points array
-
- for col in columns:
+ columns_to_check = (
+ REQUIRED_COLUMNS + ["radius"] if add_radius else REQUIRED_COLUMNS
+ ) # columns to check for in the DataFrame
+ columns_to_check = columns_to_check + extra_cols
+ print("point_cols:", points_cols)
+ print("columns_to_check:", columns_to_check)
+
+ for col in columns_to_check:
if col not in df.columns:
raise ValueError(f"Column '{col}' not found in the DataFrame")
@@ -144,7 +153,7 @@ def convert_dataframe_to_zarr(
n_tracklets = df["track_id"].nunique()
# (z, y, x) + extra_cols
- num_values_per_point = 3 + len(extra_cols)
+ num_values_per_point = 4 if add_radius else 3
# store the points in an array
points_array = (
@@ -154,6 +163,14 @@ def convert_dataframe_to_zarr(
)
* INF_SPACE
)
+ attribute_array_empty = (
+ np.ones(
+ (n_time_points, max_values_per_time_point),
+ dtype=np.float32,
+ )
+ * INF_SPACE
+ )
+ attribute_arrays = {}
points_to_tracks = lil_matrix(
(n_time_points * max_values_per_time_point, n_tracklets), dtype=np.int32
@@ -165,10 +182,18 @@ def convert_dataframe_to_zarr(
points_array[t, : group_size * num_values_per_point] = (
group[points_cols].to_numpy().ravel()
)
+
points_ids = t * max_values_per_time_point + np.arange(group_size)
points_to_tracks[points_ids, group["track_id"] - 1] = 1
+ for col in extra_cols:
+ attribute_array = attribute_array_empty.copy()
+ for t, group in df.groupby("t"):
+ group_size = len(group)
+ attribute_array[t, :group_size] = group[col].to_numpy().ravel()
+ attribute_arrays[col] = attribute_array
+
LOG.info(f"Munged {len(df)} points in {time.monotonic() - start} seconds")
# creating mapping of tracklets parent-child relationship
@@ -233,16 +258,31 @@ def convert_dataframe_to_zarr(
chunks=(1, points_array.shape[1]),
dtype=np.float32,
)
+ print("points shape:", points.shape)
points.attrs["values_per_point"] = num_values_per_point
+ if len(extra_cols) > 0:
+ attributes_matrix = np.hstack(
+ [attribute_arrays[attr] for attr in attribute_arrays]
+ )
+ attributes = top_level_group.create_dataset(
+ "attributes",
+ data=attributes_matrix,
+ chunks=(1, attribute_array.shape[1]),
+ dtype=np.float32,
+ )
+ attributes.attrs["columns"] = extra_cols
+ attributes.attrs["pre_normalized"] = pre_normalized
+
mean = df[["z", "y", "x"]].mean()
extent = (df[["z", "y", "x"]] - mean).abs().max()
extent_xyz = extent.max()
for col in ("z", "y", "x"):
points.attrs[f"mean_{col}"] = mean[col]
+
points.attrs["extent_xyz"] = extent_xyz
- points.attrs["fields"] = ["z", "y", "x"] + extra_cols
+ points.attrs["fields"] = points_cols
points.attrs["ndim"] = 2 if flag_2D else 3
top_level_group.create_groups(
@@ -355,10 +395,26 @@ def dataframe_to_browser(df: pd.DataFrame, zarr_dir: Path) -> None:
default=False,
type=bool,
)
+@click.option(
+ "--add_attributes",
+ is_flag=True,
+ help="Boolean indicating whether to include extra columns of the CSV as attributes for colors the cells in the viewer",
+ default=False,
+ type=bool,
+)
+@click.option(
+ "--pre_normalized",
+ is_flag=True,
+ help="Boolean indicating whether the extra columns with attributes are prenormalized to [0,1]",
+ default=False,
+ type=bool,
+)
def convert_cli(
csv_file: Path,
out_dir: Path | None,
add_radius: bool,
+ add_attributes: bool,
+ pre_normalized: bool,
) -> None:
"""
Convert a CSV of tracks to a sparse Zarr store
@@ -372,16 +428,22 @@ def convert_cli(
zarr_path = out_dir / f"{csv_file.stem}_bundle.zarr"
- extra_cols = ["radius"] if add_radius else []
-
tracks_df = pd.read_csv(csv_file)
+ extra_cols = []
+ if add_attributes:
+ columns_standard = REQUIRED_COLUMNS
+ extra_cols = tracks_df.columns.difference(columns_standard).to_list()
+ print("extra_cols:", extra_cols)
+
LOG.info(f"Read {len(tracks_df)} points in {time.monotonic() - start} seconds")
convert_dataframe_to_zarr(
tracks_df,
zarr_path,
+ add_radius,
extra_cols=extra_cols,
+ pre_normalized=pre_normalized,
)
LOG.info(f"Full conversion took {time.monotonic() - start} seconds")
diff --git a/src/components/App.tsx b/src/components/App.tsx
index 46d82a9d..f036abf3 100644
--- a/src/components/App.tsx
+++ b/src/components/App.tsx
@@ -16,8 +16,9 @@ import { TrackManager, loadTrackManager } from "@/lib/TrackManager";
import { PointSelectionMode } from "@/lib/PointSelector";
import LeftSidebarWrapper from "./leftSidebar/LeftSidebarWrapper";
// import { TimestampOverlay } from "./overlays/TimestampOverlay";
-import { ColorMap } from "./overlays/ColorMap";
+import { ColorMapTracks, ColorMapCells } from "./overlays/ColorMap.tsx";
import { TrackDownloadData } from "./DownloadButton";
+import { numberOfDefaultColorByOptions } from "@/components/leftSidebar/DynamicDropdown.tsx";
import config from "../../CONFIG.ts";
const brandingName = config.branding.name || undefined;
@@ -222,6 +223,7 @@ export default function App() {
const getPoints = async (time: number) => {
console.debug("fetch points at time %d", time);
const data = await trackManager.fetchPointsAtTime(time);
+ // console.log('data shape:', data.length, 'attributes shape:', attributes.length);
console.debug("got %d points for time %d", data.length / 3, time);
if (ignore) {
@@ -229,10 +231,18 @@ export default function App() {
return;
}
+ let attributes;
+ if (canvas.colorByEvent.action === "provided" || canvas.colorByEvent.action === "provided-normalized") {
+ attributes = await trackManager.fetchAttributessAtTime(
+ time,
+ canvas.colorByEvent.label - numberOfDefaultColorByOptions,
+ );
+ }
+
// clearing the timeout prevents the loading indicator from showing at all if the fetch is fast
clearTimeout(loadingTimeout);
setIsLoadingPoints(false);
- dispatchCanvas({ type: ActionType.POINTS_POSITIONS, positions: data });
+ dispatchCanvas({ type: ActionType.POINTS_POSITIONS, positions: data, attributes: attributes });
};
getPoints(canvas.curTime);
} else {
@@ -250,7 +260,7 @@ export default function App() {
clearTimeout(loadingTimeout);
ignore = true;
};
- }, [canvas.curTime, dispatchCanvas, trackManager]);
+ }, [canvas.curTime, canvas.colorByEvent, dispatchCanvas, trackManager]);
// This fetches track IDs based on the selected point IDs.
useEffect(() => {
@@ -459,6 +469,13 @@ export default function App() {
toggleAxesVisible={() => {
dispatchCanvas({ type: ActionType.TOGGLE_AXES });
}}
+ colorBy={canvas.colorBy}
+ toggleColorBy={(colorBy: boolean) => {
+ dispatchCanvas({ type: ActionType.TOGGLE_COLOR_BY, colorBy });
+ }}
+ changeColorBy={(event: string) => {
+ dispatchCanvas({ type: ActionType.CHANGE_COLOR_BY, event });
+ }}
/>
@@ -501,7 +518,8 @@ export default function App() {
>
0} />
{/* */}
-
+ {numSelectedCells > 0 && }
+ {canvas.colorByEvent.type !== "default" && }
{/* The playback controls */}
diff --git a/src/components/DataControls.tsx b/src/components/DataControls.tsx
index 7a6f167a..c9b7136b 100644
--- a/src/components/DataControls.tsx
+++ b/src/components/DataControls.tsx
@@ -149,8 +149,8 @@ export default function DataControls(props: DataControlsProps) {
diff --git a/src/components/PlaybackControls.tsx b/src/components/PlaybackControls.tsx
index a47e8c02..0f085d33 100644
--- a/src/components/PlaybackControls.tsx
+++ b/src/components/PlaybackControls.tsx
@@ -15,7 +15,7 @@ interface PlaybackControlsProps {
export default function PlaybackControls(props: PlaybackControlsProps) {
return (
-
+
+ {/* ColorBy toggle */}
+
+
+
+ {
+ props.toggleColorBy((e.target as HTMLInputElement).checked);
+ }}
+ />
+
+
+
+ {/* Color cells by dropdown */}
+ {props.colorBy == true && (
+
+
+
+ )}
+
{/* Cell size slider */}
{numberOfValuesPerPoint !== 4 && (
<>
@@ -87,6 +113,7 @@ export default function TrackControls(props: TrackControlsProps) {
Cell Size
+
+
)}
{props.hasTracks && props.showTrackHighlights && (
{
+export const ColorMapTracks = () => {
+ highlightLUT.setColorMap(colormapTracks);
const colors = Array.from({ length: 129 }, (_, i) => `#${highlightLUT.getColor(i / 128).getHexString()}`);
const gradient = `linear-gradient(to top, ${colors.join(", ")})`;
@@ -13,19 +19,20 @@ export const ColorMap = () => {
position: "absolute",
bottom: "0.5rem",
right: "0.5rem",
- width: "2.5rem",
- height: "6.5rem",
+ width: "4.5rem",
+ height: "9.5rem",
backgroundColor: "rgba(255, 255, 255, 0.6)",
zIndex: 100,
borderRadius: "var(--sds-corner-m)",
display: "flex",
flexDirection: "column",
alignItems: "center",
- fontSize: "0.6875rem",
+ fontSize: "1rem",
letterSpacing: "var(--sds-font-body-xxxs-400-letter-spacing)",
}}
>
- Future
+ Tracks {/* First line bold */}
+ Future {/* Second line lighter */}
{
display: "flex",
flexDirection: "column",
flexGrow: 1,
- width: "var(--sds-space-xxs)",
+ width: "var(--sds-space-m)",
background: gradient,
}}
/>
- Past
+ Past {/* Second line lighter */}
+
+ );
+};
+
+interface ColormapCellsProps {
+ colorByEvent: Option; // Currently timestamp is a frame number, but it could be the actual timestamp
+}
+
+export const ColorMapCells = (props: ColormapCellsProps) => {
+ let colormapString;
+ let numSteps = 129;
+ if (props.colorByEvent.type === "categorical") {
+ colormapString = colormapColorbyCategorical;
+ numSteps = props.colorByEvent.numCategorical || 129;
+ } else if (props.colorByEvent.type === "continuous") {
+ colormapString = colormapColorbyContinuous;
+ } else {
+ colormapString = "magma";
+ }
+
+ highlightLUT.setColorMap(colormapString);
+
+ // const colors = Array.from({ length: 129 }, (_, i) => `#${highlightLUT.getColor(i / 128).getHexString()}`);
+ // const gradient = `linear-gradient(to top, ${colors.join(", ")})`;
+
+ const colors = Array.from(
+ { length: numSteps },
+ (_, i) => `#${highlightLUT.getColor(i / (numSteps - 1)).getHexString()}`,
+ );
+
+ // Construct a discrete gradient
+ const gradientStops = colors.map((color, index) => {
+ const start = (index / numSteps) * 100;
+ const end = ((index + 1) / numSteps) * 100;
+ return `${color} ${start}%, ${color} ${end}%`;
+ });
+ const gradient = `linear-gradient(to top, ${gradientStops.join(", ")})`;
+
+ return (
+
+ Cells {/* First line bold */}
+ {props.colorByEvent.name.substring(0, 8) + "."}{" "}
+ {/* Second line lighter */}
+
+ Placeholder {/* Invisible, keeps alignment */}
);
};
diff --git a/src/hooks/usePointCanvas.ts b/src/hooks/usePointCanvas.ts
index 38d2a1c1..8632cd24 100644
--- a/src/hooks/usePointCanvas.ts
+++ b/src/hooks/usePointCanvas.ts
@@ -29,6 +29,8 @@ enum ActionType {
MOBILE_SELECT_CELLS = "MOBILE_SELECT_CELLS",
SELECTOR_SCALE = "SELECTOR_SCALE",
TOGGLE_AXES = "TOGGLE_AXES",
+ TOGGLE_COLOR_BY = "TOGGLE_COLOR_BY",
+ CHANGE_COLOR_BY = "CHANGE_COLOR_BY",
}
interface AutoRotate {
@@ -73,9 +75,10 @@ interface PointSizes {
interface PointsPositions {
type: ActionType.POINTS_POSITIONS;
positions: Float32Array;
+ attributes: Float32Array | undefined;
}
-interface PointColors {
+interface ResetPointColors {
type: ActionType.RESET_POINTS_COLORS;
}
@@ -147,6 +150,16 @@ interface ToggleAxes {
type: ActionType.TOGGLE_AXES;
}
+interface ToggleColorBy {
+ type: ActionType.TOGGLE_COLOR_BY;
+ colorBy: boolean;
+}
+
+interface ChangeColorBy {
+ type: ActionType.CHANGE_COLOR_BY;
+ event: string;
+}
+
// setting up a tagged union for the actions
type PointCanvasAction =
| AutoRotate
@@ -158,7 +171,7 @@ type PointCanvasAction =
| PointBrightness
| PointSizes
| PointsPositions
- | PointColors
+ | ResetPointColors
| RemoveLastSelection
| Refresh
| RemoveAllTracks
@@ -172,7 +185,9 @@ type PointCanvasAction =
| UpdateWithState
| MobileSelectCells
| SelectorScale
- | ToggleAxes;
+ | ToggleAxes
+ | ToggleColorBy
+ | ChangeColorBy;
function reducer(canvas: PointCanvas, action: PointCanvasAction): PointCanvas {
console.debug("usePointCanvas.reducer: ", action);
@@ -215,11 +230,11 @@ function reducer(canvas: PointCanvas, action: PointCanvasAction): PointCanvas {
break;
case ActionType.POINT_SIZES:
newCanvas.pointSize = action.pointSize;
- newCanvas.setPointsSizes();
+ newCanvas.updatePointsSizes();
break;
case ActionType.POINTS_POSITIONS:
newCanvas.setPointsPositions(action.positions);
- newCanvas.resetPointColors();
+ newCanvas.resetPointColors(action.attributes);
newCanvas.updateSelectedPointIndices();
newCanvas.updatePreviewPoints();
break;
@@ -304,6 +319,14 @@ function reducer(canvas: PointCanvas, action: PointCanvasAction): PointCanvas {
case ActionType.TOGGLE_AXES:
newCanvas.toggleAxesHelper();
break;
+ case ActionType.TOGGLE_COLOR_BY:
+ newCanvas.colorBy = action.colorBy;
+ newCanvas.changeColorBy("uniform");
+ break;
+ case ActionType.CHANGE_COLOR_BY:
+ newCanvas.changeColorBy(action.event);
+ // newCanvas.resetPointColors(); //happens now with useEffect in App.tsx
+ break;
default:
console.warn("usePointCanvas reducer - unknown action type: %s", action);
return canvas;
diff --git a/src/lib/Colormaps.ts b/src/lib/Colormaps.ts
new file mode 100644
index 00000000..7d657f84
--- /dev/null
+++ b/src/lib/Colormaps.ts
@@ -0,0 +1,249 @@
+import { Color } from "three";
+import { Lut } from "three/examples/jsm/Addons.js";
+
+// generated using https://waldyrious.net/viridis-palette-generator/
+// and: https://hauselin.github.io/colorpalettejs/
+
+class ExtendedLut extends Lut {
+ private colormapNames: Set = new Set();
+
+ // Override addColorMap to track colormap names
+ addColorMap(name: string, colors: [number, number][]): this {
+ super.addColorMap(name, colors);
+ this.colormapNames.add(name);
+ return this; // Ensure it returns 'this' for method chaining
+ }
+
+ // Override setColorMap with fallback behavior
+ setColorMap(colormap?: string, numberofcolors?: number): this {
+ if (colormap && !this.colormapNames.has(colormap)) {
+ console.error(`Invalid colormap name: '${colormap}'. Reverting to the default colormap: 'viridis'.`);
+ colormap = "viridis"; // Set to the default colormap
+ }
+
+ return super.setColorMap(colormap, numberofcolors); // Call the parent method with the (possibly corrected) name
+ }
+
+ // Method to retrieve all available colormap names
+ getColormapNames(): string[] {
+ return Array.from(this.colormapNames);
+ }
+}
+
+export const colormaps = new ExtendedLut();
+
+colormaps.addColorMap("viridis", [
+ [0.0, 0x440154], // purple
+ [0.1, 0x482475],
+ [0.2, 0x414487],
+ [0.3, 0x355f8d],
+ [0.4, 0x2a788e],
+ [0.5, 0x21918c],
+ [0.6, 0x22a884],
+ [0.7, 0x44bf70],
+ [0.8, 0x7ad151],
+ [0.9, 0xbddf26],
+ [1.0, 0xfcffa4], // yellow
+]);
+
+// colormaps.addColorMap("viridis-clipped", [
+// // viridis with clipped extreme purple/yellow ends
+// [0.0, 0x482475],
+// [0.125, 0x414487], // purple
+// [0.25, 0x355f8d],
+// [0.375, 0x2a788e],
+// [0.5, 0x21918c],
+// [0.625, 0x22a884],
+// [0.75, 0x44bf70],
+// [0.875, 0x7ad151], // yellow
+// [1.0, 0xbddf26],
+// ]);
+
+colormaps.addColorMap("magma", [
+ [0.0, 0x000004],
+ [0.1, 0x140e36],
+ [0.2, 0x3b0f70],
+ [0.3, 0x641a80],
+ [0.4, 0x8c2981],
+ [0.5, 0xb73779],
+ [0.6, 0xde4968],
+ [0.7, 0xf7705c],
+ [0.8, 0xfe9f6d],
+ [0.9, 0xfecf92],
+ [1.0, 0xfcffa4],
+]);
+
+colormaps.addColorMap("inferno", [
+ [0.0, 0x000004],
+ [0.1, 0x160b39],
+ [0.2, 0x420a68],
+ [0.3, 0x6a176e],
+ [0.4, 0x932667],
+ [0.5, 0xbc3754],
+ [0.6, 0xdd513a],
+ [0.7, 0xf37819],
+ [0.8, 0xfca50a],
+ [0.9, 0xf6d746],
+ [1.0, 0xfcffa4],
+]);
+
+colormaps.addColorMap("plasma", [
+ [0.0, 0x0d0887],
+ [0.1, 0x41049d],
+ [0.2, 0x6a00a8],
+ [0.3, 0x8f0da4],
+ [0.4, 0xb12a90],
+ [0.5, 0xcc4778],
+ [0.6, 0xe16462],
+ [0.7, 0xf2844b],
+ [0.8, 0xfca636],
+ [0.9, 0xfcce25],
+ [1.0, 0xfcffa4],
+]);
+
+colormaps.addColorMap("cividis", [
+ [0.0, 0x002051],
+ [0.1, 0x0d346b],
+ [0.2, 0x33486e],
+ [0.3, 0x575c6e],
+ [0.4, 0x737172],
+ [0.5, 0x8b8677],
+ [0.6, 0xa49d78],
+ [0.7, 0xc3b56d],
+ [0.8, 0xe6cf59],
+ [0.9, 0xfdea45],
+ [1.0, 0xfcffa4],
+]);
+
+colormaps.addColorMap("magma-inferno", [
+ // magma_inv + inferno
+ [0.0, 0x000004],
+ [0.05, 0x140e36],
+ [0.1, 0x3b0f70],
+ [0.15, 0x641a80],
+ [0.2, 0x8c2981],
+ [0.25, 0xb73779],
+ [0.3, 0xde4968],
+ [0.35, 0xf7705c],
+ [0.4, 0xfe9f6d],
+ [0.45, 0xfecf92],
+ [0.5, 0xfcffa4], // bright center
+ [0.55, 0xf6d746],
+ [0.6, 0xfca50a],
+ [0.65, 0xf37819],
+ [0.7, 0xdd513a],
+ [0.75, 0xbc3754],
+ [0.8, 0x932667],
+ [0.85, 0x6a176e],
+ [0.9, 0x420a68],
+ [0.95, 0x160b39],
+ [1.0, 0x000004],
+]);
+colormaps.addColorMap("viridis-inferno", [
+ // viridis_inv + inferno
+ [0.0, 0x440154],
+ [0.05, 0x482475],
+ [0.1, 0x414487],
+ [0.15, 0x355f8d],
+ [0.2, 0x2a788e],
+ [0.25, 0x21918c],
+ [0.3, 0x22a884],
+ [0.35, 0x44bf70],
+ [0.4, 0x7ad151],
+ [0.45, 0xbddf26],
+ [0.5, 0xfcffa4], // bright center
+ [0.55, 0xf6d746],
+ [0.6, 0xfca50a],
+ [0.65, 0xf37819],
+ [0.7, 0xdd513a],
+ [0.75, 0xbc3754],
+ [0.8, 0x932667],
+ [0.85, 0x6a176e],
+ [0.9, 0x420a68],
+ [0.95, 0x160b39],
+ [1.0, 0x000004],
+]);
+colormaps.addColorMap("inferno-inferno", [
+ // inferno_inv + inferno
+ [0.0, 0x000004],
+ [0.05, 0x160b39],
+ [0.1, 0x420a68],
+ [0.15, 0x6a176e],
+ [0.2, 0x932667],
+ [0.25, 0xbc3754],
+ [0.3, 0xdd513a],
+ [0.35, 0xf37819],
+ [0.4, 0xfca50a],
+ [0.45, 0xf6d746],
+ [0.5, 0xfcffa4], // bright center
+ [0.55, 0xf6d746],
+ [0.6, 0xfca50a],
+ [0.65, 0xf37819],
+ [0.7, 0xdd513a],
+ [0.75, 0xbc3754],
+ [0.8, 0x932667],
+ [0.85, 0x6a176e],
+ [0.9, 0x420a68],
+ [0.95, 0x160b39],
+ [1.0, 0x000004],
+]);
+colormaps.addColorMap("plasma-inferno", [
+ // plasma_inv + inferno
+ [0.0, 0x0d0887],
+ [0.05, 0x41049d],
+ [0.1, 0x6a00a8],
+ [0.15, 0x8f0da4],
+ [0.2, 0xb12a90],
+ [0.25, 0xcc4778],
+ [0.3, 0xe16462],
+ [0.35, 0xf2844b],
+ [0.4, 0xfca636],
+ [0.45, 0xfcce25],
+ [0.5, 0xfcffa4], // bright center
+ [0.55, 0xf6d746],
+ [0.6, 0xfca50a],
+ [0.65, 0xf37819],
+ [0.7, 0xdd513a],
+ [0.75, 0xbc3754],
+ [0.8, 0x932667],
+ [0.85, 0x6a176e],
+ [0.9, 0x420a68],
+ [0.95, 0x160b39],
+ [1.0, 0x000004],
+]);
+colormaps.addColorMap("cividis-inferno", [
+ // cividis_inv + inferno
+ [0.0, 0x002051],
+ [0.05, 0x0d346b],
+ [0.1, 0x33486e],
+ [0.15, 0x575c6e],
+ [0.2, 0x737172],
+ [0.25, 0x8b8677],
+ [0.3, 0xa49d78],
+ [0.35, 0xc3b56d],
+ [0.4, 0xe6cf59],
+ [0.45, 0xfdea45],
+ [0.5, 0xfcffa4], // bright center
+ [0.55, 0xf6d746],
+ [0.6, 0xfca50a],
+ [0.65, 0xf37819],
+ [0.7, 0xdd513a],
+ [0.75, 0xbc3754],
+ [0.8, 0x932667],
+ [0.85, 0x6a176e],
+ [0.9, 0x420a68],
+ [0.95, 0x160b39],
+ [1.0, 0x000004],
+]);
+
+// Generate the categorical HSL colormap
+const numCategories = 10; // Replace with the actual number of categories
+const categoricalColormap: [number, number][] = [];
+for (let i = 0; i < numCategories; i++) {
+ const scalar = i / (numCategories - 1); // Normalized scalar in [0, 1]
+ const hue = scalar * 0.75; // remove purple and red at the end op spectrum
+ const color = new Color();
+ color.setHSL(hue, 1, 0.4);
+ categoricalColormap.push([scalar, color.getHex()]);
+}
+colormaps.addColorMap("HSL", categoricalColormap);
diff --git a/src/lib/PointCanvas.ts b/src/lib/PointCanvas.ts
index 9b74b991..dcda57ed 100644
--- a/src/lib/PointCanvas.ts
+++ b/src/lib/PointCanvas.ts
@@ -1,9 +1,11 @@
import {
AxesHelper,
+ BufferAttribute,
BufferGeometry,
Color,
Float32BufferAttribute,
FogExp2,
+ InterleavedBufferAttribute,
NormalBlending,
PerspectiveCamera,
Points,
@@ -24,6 +26,8 @@ import { Track } from "@/lib/three/Track";
import { PointSelector, PointSelectionMode } from "@/lib/PointSelector";
import { ViewerState } from "./ViewerState";
import { numberOfValuesPerPoint } from "./TrackManager";
+import { Option, dropDownOptions } from "@/components/leftSidebar/DynamicDropdown";
+import { colormaps } from "@/lib/Colormaps";
import { detectedDevice } from "@/components/App.tsx";
import config from "../../CONFIG.ts";
@@ -31,6 +35,8 @@ const initialPointSize = config.settings.point_size;
const pointColor = config.settings.point_color;
const highlightPointColor = config.settings.highlight_point_color;
const previewHighlightPointColor = config.settings.preview_hightlight_point_color;
+const colormapColorbyCategorical = config.settings.colormap_colorby_categorical;
+const colormapColorbyContinuous = config.settings.colormap_colorby_continuous;
const trackWidthRatio = 0.07; // DONT CHANGE: factor of 0.07 is needed to make tracks equally wide as the points
const factorPointSizeVsCellSize = 0.1; // DONT CHANGE: this value relates the actual size of the points to the size of the points in the viewer
@@ -85,6 +91,9 @@ export class PointCanvas {
// tracks but could be pulled from the points geometry when adding tracks
maxPointsPerTimepoint = 0;
private pointIndicesCache: Map = new Map();
+ colorBy: boolean = false;
+ colorByEvent: Option = dropDownOptions[0];
+ currentAttributes: number[] | Float32Array = new Float32Array();
constructor(width: number, height: number) {
this.scene = new Scene();
@@ -291,6 +300,16 @@ export class PointCanvas {
this.pointIndicesCache.clear();
}
+ changeColorBy(eventName: string) {
+ const selectedOption = dropDownOptions.find((option) => option.name === eventName);
+ if (selectedOption) {
+ this.colorByEvent = selectedOption;
+ console.debug(`ColorBy attribute selected: ${selectedOption.name}`);
+ } else {
+ console.error(`No option found with name: ${eventName}`);
+ }
+ }
+
highlightPoints(points: number[]) {
const colorAttribute = this.points.geometry.getAttribute("color");
const color = new Color();
@@ -325,20 +344,122 @@ export class PointCanvas {
colorAttribute.needsUpdate = true;
}
- resetPointColors() {
+ resetPointColors(attributesInput?: Float32Array) {
if (!this.points.geometry.hasAttribute("color")) {
return;
}
- const color = new Color();
- color.setRGB(pointColor[0], pointColor[1], pointColor[2], SRGBColorSpace); // cyan/turquoise
- color.multiplyScalar(this.pointBrightness);
+
const colorAttribute = this.points.geometry.getAttribute("color");
- for (let i = 0; i < colorAttribute.count; i++) {
- colorAttribute.setXYZ(i, color.r, color.g, color.b);
+ const geometry = this.points.geometry;
+ const numPoints = geometry.drawRange.count;
+ const positions = geometry.getAttribute("position");
+
+ let attributes;
+ if (this.colorByEvent.action === "default") {
+ attributes = new Float32Array(numPoints).fill(1); // all 1
+ console.debug("Default attributes (1)");
+ } else {
+ if (this.colorByEvent.action === "calculate") {
+ attributes = this.calculateAttributeVector(positions, this.colorByEvent, numPoints); // calculated attributes based on position
+ console.debug("Attributes calculated");
+ } else if (this.colorByEvent.action === "provided" || this.colorByEvent.action === "provided-normalized") {
+ if (attributesInput) {
+ attributes = attributesInput; // take provided attributes fetched from Zarr
+ this.currentAttributes = attributes;
+ console.debug("Attributes provided, using attributesInput");
+ } else {
+ attributes = this.currentAttributes;
+ console.debug("No attributes provided, using currentAttributes");
+ }
+ } else {
+ console.error("Invalid action type for colorByEvent:", this.colorByEvent.action);
+ }
+ if (attributes) {
+ if (this.colorByEvent.action != "provided-normalized") {
+ attributes = this.normalizeAttributeVector(attributes);
+ }
+ } else {
+ attributes = new Float32Array(numPoints).fill(1);
+ console.error("No attributes found for colorByEvent:", this.colorByEvent);
+ }
+ }
+
+ if (this.colorByEvent.type === "default") {
+ const color = new Color();
+ color.setRGB(pointColor[0], pointColor[1], pointColor[2], SRGBColorSpace); // cyan/turquoise
+ color.multiplyScalar(this.pointBrightness);
+ for (let i = 0; i < numPoints; i++) {
+ colorAttribute.setXYZ(i, color.r, color.g, color.b);
+ }
+ } else {
+ const color = new Color();
+ if (this.colorByEvent.type === "categorical") {
+ colormaps.setColorMap(colormapColorbyCategorical, 50);
+ } else if (this.colorByEvent.type === "continuous") {
+ colormaps.setColorMap(colormapColorbyContinuous, 50);
+ }
+ for (let i = 0; i < numPoints; i++) {
+ const scalar = attributes[i]; // must be [0 1]
+ const colorOfScalar = colormaps.getColor(scalar); // remove the bright/dark edges of colormap
+ // const colorOfScalar = colormaps.getColor(scalar*0.8+0.1); //remove the bright/dark edges of colormap
+ color.setRGB(colorOfScalar.r, colorOfScalar.g, colorOfScalar.b, SRGBColorSpace);
+ color.multiplyScalar(this.pointBrightness);
+ colorAttribute.setXYZ(i, color.r, color.g, color.b);
+ }
}
colorAttribute.needsUpdate = true;
}
+ calculateAttributeVector(
+ positions: BufferAttribute | InterleavedBufferAttribute,
+ colorByEvent: Option,
+ numPoints: number,
+ ): number[] {
+ const attributeVector = [];
+ // const numPoints = positions.count / numberOfValuesPerPoint;
+ // console.log('numPoints in getAtt:',numPoints, positions.count, numberOfValuesPerPoint)
+ // console.log('positions:',positions)
+
+ for (let i = 0; i < numPoints; i++) {
+ if (colorByEvent.name === "uniform") {
+ attributeVector.push(1); // constant color
+ } else if (colorByEvent.name === "x-position") {
+ attributeVector.push(positions.getX(i) + 1000); // color based on X coordinate
+ } else if (colorByEvent.name === "y-position") {
+ attributeVector.push(positions.getY(i)); // color based on Y coordinate
+ } else if (colorByEvent.name === "z-position") {
+ attributeVector.push(positions.getZ(i)); // color based on Z coordinate
+ } else if (colorByEvent.name === "sign(x-pos)") {
+ const bool = positions.getX(i) < 0;
+ attributeVector.push(bool ? 0 : 1); // color based on X coordinate (2 groups)
+ } else if (colorByEvent.name === "quadrants") {
+ const x = positions.getX(i) > 0 ? 1 : 0;
+ const y = positions.getY(i) > 0 ? 1 : 0;
+ const z = positions.getZ(i) > 0 ? 1 : 0;
+ const quadrant = x + y * 2 + z * 4; //
+ attributeVector.push(quadrant); // color based on XY coordinates (4 groups)
+ } else {
+ attributeVector.push(1); // default to constant color if event type not recognized
+ }
+ }
+
+ return attributeVector;
+ }
+
+ normalizeAttributeVector(attributes: number[] | Float32Array): number[] | Float32Array {
+ const min = Math.min(...attributes);
+ const max = Math.max(...attributes);
+ // const max = 4000.0;
+ const range = max - min;
+
+ // Avoid division by zero in case all values are the same
+ if (range === 0) {
+ return attributes.map(() => 1); // Arbitrary choice: map all to the midpoint (0.5)
+ }
+
+ return attributes.map((value) => (value - min) / range);
+ }
+
removeLastSelection() {
this.selectedPointIds = new Set(this.fetchedPointIds);
}
@@ -372,7 +493,7 @@ export class PointCanvas {
this.resetPointColors();
}
- setPointsSizes() {
+ updatePointsSizes() {
const geometry = this.points.geometry;
const sizes = geometry.getAttribute("size");
@@ -402,6 +523,7 @@ export class PointCanvas {
const geometry = this.points.geometry;
const positions = geometry.getAttribute("position");
const sizes = geometry.getAttribute("size");
+
const num = numberOfValuesPerPoint;
// if the point size is the initial point size and radius is provided, then we need to calculate the mean cell size once
diff --git a/src/lib/TrackManager.ts b/src/lib/TrackManager.ts
index 9aadfc63..047713b4 100644
--- a/src/lib/TrackManager.ts
+++ b/src/lib/TrackManager.ts
@@ -1,5 +1,6 @@
// @ts-expect-error - types for zarr are not working right now, but a PR is open https://github.com/gzuidhof/zarr.js/pull/149
import { ZarrArray, slice, Slice, openArray, NestedArray } from "zarr";
+import { addDropDownOption, dropDownOptions, resetDropDownOptions } from "@/components/leftSidebar/DynamicDropdown.tsx";
export let numberOfValuesPerPoint = 0; // 3 if points=[x,y,z], 4 if points=[x,y,z,size]
import config from "../../CONFIG.ts";
@@ -106,6 +107,7 @@ export class TrackManager {
pointsToTracks: SparseZarrArray;
tracksToPoints: SparseZarrArray;
tracksToTracks: SparseZarrArray;
+ attributes: ZarrArray;
numTimes: number;
maxPointsPerTimepoint: number;
scaleSettings: ScaleSettings;
@@ -118,6 +120,7 @@ export class TrackManager {
pointsToTracks: SparseZarrArray,
tracksToPoints: SparseZarrArray,
tracksToTracks: SparseZarrArray,
+ attributes: ZarrArray,
scaleSettings: ScaleSettings,
) {
this.store = store;
@@ -125,6 +128,7 @@ export class TrackManager {
this.pointsToTracks = pointsToTracks;
this.tracksToPoints = tracksToPoints;
this.tracksToTracks = tracksToTracks;
+ this.attributes = attributes;
this.numTimes = points.shape[0];
this.maxPointsPerTimepoint = points.shape[1] / numberOfValuesPerPoint; // default is /3
this.scaleSettings = scaleSettings;
@@ -153,6 +157,28 @@ export class TrackManager {
return array;
}
+ async fetchAttributessAtTime(timeIndex: number, attributeIndex: number): Promise {
+ console.debug("fetchAttributessAtTime, time=%d, attribute=%d", timeIndex, attributeIndex);
+
+ const startColumn = attributeIndex * this.maxPointsPerTimepoint;
+ const endColumn = startColumn + this.maxPointsPerTimepoint;
+
+ const attributes: Float32Array = (await this.attributes.get([timeIndex, slice(startColumn, endColumn)])).data;
+
+ // assume points < -127 are invalid, and all are at the end of the array
+ // this is how the jagged array is stored in the zarr
+ // for Float32 it's actually -9999, but the int8 data is -127
+ let endIndex = attributes.findIndex((value) => value <= -127);
+ if (endIndex === -1) {
+ endIndex = attributes.length;
+ }
+
+ // scale the data to fit in the viewer
+ const array = attributes.subarray(0, endIndex);
+
+ return array;
+ }
+
async fetchTrackIDsForPoint(pointID: number): Promise {
const rowStartEnd = await this.pointsToTracks.getIndPtr(slice(pointID, pointID + 2));
const trackIDs = await this.pointsToTracks.indices.get([slice(rowStartEnd[0], rowStartEnd[1])]);
@@ -227,6 +253,7 @@ export class TrackManager {
export async function loadTrackManager(url: string) {
let trackManager;
try {
+ // console.log('url', url);
const points = await openArray({
store: url,
path: "points",
@@ -258,8 +285,41 @@ export async function loadTrackManager(url: string) {
const tracksToPoints = await openSparseZarrArray(url, "tracks_to_points", true);
const tracksToTracks = await openSparseZarrArray(url, "tracks_to_tracks", true);
+ let attributes = null;
+ resetDropDownOptions();
+ try {
+ attributes = await openArray({
+ store: url,
+ path: "attributes",
+ mode: "r",
+ });
+ const zattrs = await attributes.attrs.asObject();
+ console.debug("attributes found: %s", zattrs["columns"]);
+
+ for (let column = 0; column < zattrs["columns"].length; column++) {
+ addDropDownOption({
+ name: zattrs["columns"][column],
+ label: dropDownOptions.length,
+ type: "continuous", // TODO: decide this in conversion script!
+ action: zattrs["pre_normalized"] ? "provided-normalized" : "provided",
+ numCategorical: undefined,
+ });
+ }
+ console.debug("dropDownOptions:", dropDownOptions);
+ } catch (error) {
+ console.log("No attributes found in Zarr");
+ }
+
// make trackManager, and reset "maxPointsPerTimepoint", because tm constructor does points/3
- trackManager = new TrackManager(url, points, pointsToTracks, tracksToPoints, tracksToTracks, scaleSettings);
+ trackManager = new TrackManager(
+ url,
+ points,
+ pointsToTracks,
+ tracksToPoints,
+ tracksToTracks,
+ attributes,
+ scaleSettings,
+ );
if (numberOfValuesPerPoint == 4) {
trackManager.maxPointsPerTimepoint = trackManager.points.shape[1] / numberOfValuesPerPoint;
}
diff --git a/src/lib/three/TrackMaterial.ts b/src/lib/three/TrackMaterial.ts
index 3744ffae..f1725509 100644
--- a/src/lib/three/TrackMaterial.ts
+++ b/src/lib/three/TrackMaterial.ts
@@ -9,6 +9,7 @@
* https://github.com/mrdoob/three.js/blob/5ed5417d63e4eeba5087437cc27ab1e3d0813aea/examples/jsm/lines/LineMaterial.js
*/
+import { colormaps } from "../Colormaps.ts";
import config from "../../../CONFIG.ts";
const colormapTracks = config.settings.colormap_tracks || "viridis-inferno";
@@ -24,132 +25,8 @@ import {
UnsignedByteType,
Vector2,
} from "three";
-import { Lut } from "three/examples/jsm/Addons.js";
-
-export const highlightLUT = new Lut();
-// generated using https://waldyrious.net/viridis-palette-generator/
-// and: https://hauselin.github.io/colorpalettejs/
-highlightLUT.addColorMap("magma-inferno", [
- // magma_inv + inferno
- [0.0, 0x000004],
- [0.05, 0x140e36],
- [0.1, 0x3b0f70],
- [0.15, 0x641a80],
- [0.2, 0x8c2981],
- [0.25, 0xb73779],
- [0.3, 0xde4968],
- [0.35, 0xf7705c],
- [0.4, 0xfe9f6d],
- [0.45, 0xfecf92],
- [0.5, 0xfcffa4], // bright center
- [0.55, 0xf6d746],
- [0.6, 0xfca50a],
- [0.65, 0xf37819],
- [0.7, 0xdd513a],
- [0.75, 0xbc3754],
- [0.8, 0x932667],
- [0.85, 0x6a176e],
- [0.9, 0x420a68],
- [0.95, 0x160b39],
- [1.0, 0x000004],
-]);
-highlightLUT.addColorMap("viridis-inferno", [
- // viridis_inv + inferno
- [0.0, 0x440154],
- [0.05, 0x482475],
- [0.1, 0x414487],
- [0.15, 0x355f8d],
- [0.2, 0x2a788e],
- [0.25, 0x21918c],
- [0.3, 0x22a884],
- [0.35, 0x44bf70],
- [0.4, 0x7ad151],
- [0.45, 0xbddf26],
- [0.5, 0xfcffa4], // bright center
- [0.55, 0xf6d746],
- [0.6, 0xfca50a],
- [0.65, 0xf37819],
- [0.7, 0xdd513a],
- [0.75, 0xbc3754],
- [0.8, 0x932667],
- [0.85, 0x6a176e],
- [0.9, 0x420a68],
- [0.95, 0x160b39],
- [1.0, 0x000004],
-]);
-highlightLUT.addColorMap("inferno-inferno", [
- // inferno_inv + inferno
- [0.0, 0x000004],
- [0.05, 0x160b39],
- [0.1, 0x420a68],
- [0.15, 0x6a176e],
- [0.2, 0x932667],
- [0.25, 0xbc3754],
- [0.3, 0xdd513a],
- [0.35, 0xf37819],
- [0.4, 0xfca50a],
- [0.45, 0xf6d746],
- [0.5, 0xfcffa4], // bright center
- [0.55, 0xf6d746],
- [0.6, 0xfca50a],
- [0.65, 0xf37819],
- [0.7, 0xdd513a],
- [0.75, 0xbc3754],
- [0.8, 0x932667],
- [0.85, 0x6a176e],
- [0.9, 0x420a68],
- [0.95, 0x160b39],
- [1.0, 0x000004],
-]);
-highlightLUT.addColorMap("plasma-inferno", [
- // plasma_inv + inferno
- [0.0, 0x0d0887],
- [0.05, 0x41049d],
- [0.1, 0x6a00a8],
- [0.15, 0x8f0da4],
- [0.2, 0xb12a90],
- [0.25, 0xcc4778],
- [0.3, 0xe16462],
- [0.35, 0xf2844b],
- [0.4, 0xfca636],
- [0.45, 0xfcce25],
- [0.5, 0xfcffa4], // bright center
- [0.55, 0xf6d746],
- [0.6, 0xfca50a],
- [0.65, 0xf37819],
- [0.7, 0xdd513a],
- [0.75, 0xbc3754],
- [0.8, 0x932667],
- [0.85, 0x6a176e],
- [0.9, 0x420a68],
- [0.95, 0x160b39],
- [1.0, 0x000004],
-]);
-highlightLUT.addColorMap("cividis-inferno", [
- // cividis_inv + inferno
- [0.0, 0x002051],
- [0.05, 0x0d346b],
- [0.1, 0x33486e],
- [0.15, 0x575c6e],
- [0.2, 0x737172],
- [0.25, 0x8b8677],
- [0.3, 0xa49d78],
- [0.35, 0xc3b56d],
- [0.4, 0xe6cf59],
- [0.45, 0xfdea45],
- [0.5, 0xfcffa4], // bright center
- [0.55, 0xf6d746],
- [0.6, 0xfca50a],
- [0.65, 0xf37819],
- [0.7, 0xdd513a],
- [0.75, 0xbc3754],
- [0.8, 0x932667],
- [0.85, 0x6a176e],
- [0.9, 0x420a68],
- [0.95, 0x160b39],
- [1.0, 0x000004],
-]);
+export const highlightLUT = colormaps;
highlightLUT.setColorMap(colormapTracks);
const lutArray = new Uint8Array(128 * 4);
for (let i = 0; i < 128; i++) {