From 8aa8d6029f9dc31515adc67f11c4d0008c610e31 Mon Sep 17 00:00:00 2001 From: Graham McNeill Date: Thu, 7 Nov 2024 12:02:41 +0000 Subject: [PATCH 01/16] add plot components to ui --- .../src/study/GWASCredibleSets/Body.tsx | 25 +- packages/ui/package.json | 1 + .../ui/src/components/Plot/ManhattanPlot.jsx | 251 +++++++++++++++++ packages/ui/src/components/Plot/README.md | 256 ++++++++++++++++++ packages/ui/src/components/Plot/TestPlot.jsx | 47 ++++ .../src/components/Plot/components/Frame.jsx | 19 ++ .../src/components/Plot/components/Panel.jsx | 22 ++ .../src/components/Plot/components/Plot.jsx | 12 + .../Plot/components/SVGContainer.jsx | 35 +++ .../src/components/Plot/components/XAxis.jsx | 27 ++ .../src/components/Plot/components/XGrid.jsx | 44 +++ .../src/components/Plot/components/XLabel.jsx | 63 +++++ .../src/components/Plot/components/XTick.jsx | 60 ++++ .../src/components/Plot/components/XTitle.jsx | 62 +++++ .../src/components/Plot/components/YAxis.jsx | 27 ++ .../src/components/Plot/components/YGrid.jsx | 44 +++ .../src/components/Plot/components/YLabel.jsx | 63 +++++ .../src/components/Plot/components/YTick.jsx | 61 +++++ .../Plot/components/marks/Circle.jsx | 86 ++++++ .../Plot/components/marks/Segment.jsx | 84 ++++++ .../components/Plot/contexts/FrameContext.jsx | 62 +++++ .../components/Plot/contexts/PlotContext.jsx | 58 ++++ .../components/Plot/contexts/VisContext.jsx | 52 ++++ .../Plot/defaults/channelDefaults.js | 40 +++ .../components/Plot/defaults/plotDefaults.js | 36 +++ packages/ui/src/components/Plot/index.js | 16 ++ .../ui/src/components/Plot/util/addXYMaps.js | 17 ++ .../ui/src/components/Plot/util/assert.js | 16 ++ .../src/components/Plot/util/baseReducer.js | 28 ++ .../ui/src/components/Plot/util/finalData.js | 10 + .../components/Plot/util/fromFrameOrPlot.js | 8 + .../ui/src/components/Plot/util/helpers.js | 19 ++ .../components/Plot/util/processAccessors.js | 67 +++++ .../ui/src/components/Plot/util/rowValues.js | 35 +++ .../src/components/Plot/util/scaleChannels.js | 22 ++ .../ui/src/components/Plot/util/scaleValue.js | 23 ++ packages/ui/src/index.tsx | 1 + yarn.lock | 2 +- 38 files changed, 1789 insertions(+), 12 deletions(-) create mode 100644 packages/ui/src/components/Plot/ManhattanPlot.jsx create mode 100644 packages/ui/src/components/Plot/README.md create mode 100644 packages/ui/src/components/Plot/TestPlot.jsx create mode 100644 packages/ui/src/components/Plot/components/Frame.jsx create mode 100644 packages/ui/src/components/Plot/components/Panel.jsx create mode 100644 packages/ui/src/components/Plot/components/Plot.jsx create mode 100644 packages/ui/src/components/Plot/components/SVGContainer.jsx create mode 100644 packages/ui/src/components/Plot/components/XAxis.jsx create mode 100644 packages/ui/src/components/Plot/components/XGrid.jsx create mode 100644 packages/ui/src/components/Plot/components/XLabel.jsx create mode 100644 packages/ui/src/components/Plot/components/XTick.jsx create mode 100644 packages/ui/src/components/Plot/components/XTitle.jsx create mode 100644 packages/ui/src/components/Plot/components/YAxis.jsx create mode 100644 packages/ui/src/components/Plot/components/YGrid.jsx create mode 100644 packages/ui/src/components/Plot/components/YLabel.jsx create mode 100644 packages/ui/src/components/Plot/components/YTick.jsx create mode 100644 packages/ui/src/components/Plot/components/marks/Circle.jsx create mode 100644 packages/ui/src/components/Plot/components/marks/Segment.jsx create mode 100644 packages/ui/src/components/Plot/contexts/FrameContext.jsx create mode 100644 packages/ui/src/components/Plot/contexts/PlotContext.jsx create mode 100644 packages/ui/src/components/Plot/contexts/VisContext.jsx create mode 100644 packages/ui/src/components/Plot/defaults/channelDefaults.js create mode 100644 packages/ui/src/components/Plot/defaults/plotDefaults.js create mode 100644 packages/ui/src/components/Plot/index.js create mode 100644 packages/ui/src/components/Plot/util/addXYMaps.js create mode 100644 packages/ui/src/components/Plot/util/assert.js create mode 100644 packages/ui/src/components/Plot/util/baseReducer.js create mode 100644 packages/ui/src/components/Plot/util/finalData.js create mode 100644 packages/ui/src/components/Plot/util/fromFrameOrPlot.js create mode 100644 packages/ui/src/components/Plot/util/helpers.js create mode 100644 packages/ui/src/components/Plot/util/processAccessors.js create mode 100644 packages/ui/src/components/Plot/util/rowValues.js create mode 100644 packages/ui/src/components/Plot/util/scaleChannels.js create mode 100644 packages/ui/src/components/Plot/util/scaleValue.js diff --git a/packages/sections/src/study/GWASCredibleSets/Body.tsx b/packages/sections/src/study/GWASCredibleSets/Body.tsx index be2b3a593..a932e427f 100644 --- a/packages/sections/src/study/GWASCredibleSets/Body.tsx +++ b/packages/sections/src/study/GWASCredibleSets/Body.tsx @@ -1,5 +1,5 @@ import { useQuery } from "@apollo/client"; -import { Link, SectionItem, ScientificNotation, DisplayVariantId, OtTable } from "ui"; +import { Link, SectionItem, ScientificNotation, DisplayVariantId, OtTable, ManhattanPlot } from "ui"; import { naLabel } from "../../constants"; import { definition } from "."; import Description from "./Description"; @@ -129,16 +129,19 @@ function Body({ id, entity }: BodyProps) { request={request} renderDescription={() => } renderBody={() => ( - + <> + + + )} /> ); diff --git a/packages/ui/package.json b/packages/ui/package.json index 1e3924cc3..62c4656b3 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -20,6 +20,7 @@ "@tanstack/table-core": "^8.16.0", "@uidotdev/usehooks": "^2.4.1", "classnames": "^2.3.2", + "d3-array": "^3.2.4", "d3-format": "^3.1.0", "file-saver": "^2.0.5", "graphql-anywhere": "^4.2.8", diff --git a/packages/ui/src/components/Plot/ManhattanPlot.jsx b/packages/ui/src/components/Plot/ManhattanPlot.jsx new file mode 100644 index 000000000..c3b2da222 --- /dev/null +++ b/packages/ui/src/components/Plot/ManhattanPlot.jsx @@ -0,0 +1,251 @@ + +import { + Plot, + XAxis, + YAxis, + XTick, + YTick, + XLabel, + YLabel, + XTitle, + XGrid, + YGrid, + Circle, + Segment, +} from '.'; +import * as d3 from "d3-array"; + +NEED TO IMPORT D3 SCALES ALSO!!!!!!!!!!!!!!!! + + +window.d3 = d3; + + +// ========== chromosome lengths ========== + +// from: https://www.ncbi.nlm.nih.gov/grc/human/data +// (first tab: "Chromosome lengths") +const chromosomeInfo = [ + { chromosome: '1', length: 248956422 }, + { chromosome: '2', length: 242193529 }, + { chromosome: '3', length: 198295559 }, + { chromosome: '4', length: 190214555 }, + { chromosome: '5', length: 181538259 }, + { chromosome: '6', length: 170805979 }, + { chromosome: '7', length: 159345973 }, + { chromosome: '8', length: 145138636 }, + { chromosome: '9', length: 138394717 }, + { chromosome: '10', length: 133797422 }, + { chromosome: '11', length: 135086622 }, + { chromosome: '12', length: 133275309 }, + { chromosome: '13', length: 114364328 }, + { chromosome: '14', length: 107043718 }, + { chromosome: '15', length: 101991189 }, + { chromosome: '16', length: 90338345 }, + { chromosome: '17', length: 83257441 }, + { chromosome: '18', length: 80373285 }, + { chromosome: '19', length: 58617616 }, + { chromosome: '20', length: 64444167 }, + { chromosome: '21', length: 46709983 }, + { chromosome: '22', length: 50818468 }, + { chromosome: 'X', length: 156040895 }, + { chromosome: 'Y', length: 57227415 }, +]; + +const cumulativeLengths = [...d3.cumsum(chromosomeInfo, d => d.length)]; + +const genomeLength = cumulativeLengths.at(-1); +cumulativeLengths.forEach((c, i) => { + const start = cumulativeLengths[i - 1] ?? 0; + chromosomeInfo[i].start = start; + chromosomeInfo[i].midpoint = (start + c) / 2; +}); + + +// ========== credible set data ========== + +// downloaded data from GWAS credible set widget on study page +// - https://genetics--ot-platform.netlify.app/study/FINNGEN_R11_G6_MYONEU +// - from genetics netlify preview, 5/11/24 +// - manually changed 'x10' scientific strings to numbers with 'e' notation +const credibleSets = [ + { + "leadVariant": "2_176833368_G_C", + "pValue": 9.812999725341797e-29, + "beta": 1.78142, + "finemappingMethod": "SuSie", + "credibleSetSize": 1 + }, + { + "leadVariant": "2_182725396_C_G", + "pValue": 2.0209999084472656e-15, + "beta": 1.88092, + "finemappingMethod": "SuSie", + "credibleSetSize": 1 + }, + { + "leadVariant": "2_178527202_G_T", + "pValue": 9.468999862670898e-43, + "beta": 2.50628, + "finemappingMethod": "SuSie", + "credibleSetSize": 2 + }, + { + "leadVariant": "3_133502828_C_T", + "pValue": 3.1429998874664307e-26, + "beta": 2.39005, + "finemappingMethod": "SuSie", + "credibleSetSize": 2 + }, + { + "leadVariant": "2_69971691_C_T", + "pValue": 1.934999942779541e-11, + "beta": 2.70286, + "finemappingMethod": "SuSie", + "credibleSetSize": 3 + }, + { + "leadVariant": "3_123909270_G_T", + "pValue": 2.119999885559082e-9, + "beta": 1.42897, + "finemappingMethod": "SuSie", + "credibleSetSize": 2 + }, + { + "leadVariant": "3_129274020_G_A", + "pValue": 9.961000442504883e-39, + "beta": 1.64242, + "finemappingMethod": "SuSie", + "credibleSetSize": 7 + }, + { + "leadVariant": "3_125959818_C_G", + "pValue": 1.0149999856948853e-38, + "beta": 2.9011, + "finemappingMethod": "SuSie", + "credibleSetSize": 2 + }, + { + "leadVariant": "13_59255897_CCT_C", + "pValue": 2.700000047683716e-8, + "beta": -0.778397, + "finemappingMethod": "SuSie", + "credibleSetSize": 5 + }, + { + "leadVariant": "3_135574097_C_A", + "pValue": 7.104000091552734e-14, + "beta": 1.89334, + "finemappingMethod": "SuSie", + "credibleSetSize": 4 + }, + { + "leadVariant": "3_128084826_G_A", + "pValue": 1.277999997138977e-66, + "beta": 3.1711, + "finemappingMethod": "SuSie", + "credibleSetSize": 1 + }, + { + "leadVariant": "2_186704353_G_A", + "pValue": 8.9350004196167e-13, + "beta": 1.81246, + "finemappingMethod": "SuSie", + "credibleSetSize": 1 + }, + { + "leadVariant": "3_137766724_A_T", + "pValue": 2.318000078201294e-11, + "beta": 1.68788, + "finemappingMethod": "SuSie", + "credibleSetSize": 6 + }, + { + "leadVariant": "7_143351678_C_T", + "pValue": 1.7300000190734863e-8, + "beta": 0.420331, + "finemappingMethod": "SuSie", + "credibleSetSize": 4 + }, + { + "leadVariant": "17_63747701_G_A", + "pValue": 1.1540000438690186e-11, + "beta": 1.81591, + "finemappingMethod": "SuSie", + "credibleSetSize": 3 + } +]; + +// currently inefficient since finds correct chromosome +function cumulativePosition(id) { + const parts = id.split('_'); // not necessary in platform since can get in query + const [chromosome, position] = [parts[0], Number(parts[1])]; + return chromosomeInfo.find(elmt => elmt.chromosome === chromosome).start + position; +} +const genomePositions = {}; +credibleSets.forEach(({ leadVariant }) => { + genomePositions[leadVariant] = cumulativePosition(leadVariant); +}); + +const pValueMin = d3.min(credibleSets, d => d.pValue); +const pValueMax = 1; + + +// ========== plot ========== + +const background = '#fff'; +const markColor = '#3489ca'; + +export default function ManhattanPlot() { + return ( + + tickData.map(chromo => chromo.start)} tickLength={15}/> + + tickData.map(chromo => chromo.midpoint)} + format={(_, i, __, tickData) => tickData[i].chromosome} + padding={6} + /> + tickData.map(chromo => chromo.start)} stroke="#ccc" /> + + -log_10(pValue) + + + + -Math.log10(v)} /> + genomePositions[d.leadVariant]} + xx={d => genomePositions[d.leadVariant]} + y={d => d.pValue} + yy={pValueMax} + fill="transparent" + stroke={markColor} + strokeWidth={1} + strokeOpacity={0.7} + area={24} + /> + genomePositions[d.leadVariant]} + y={d => d.pValue} + fill={background} + fillOpacity={1} + stroke={markColor} + strokeWidth={1.2} + area={24} + /> + + ); +} \ No newline at end of file diff --git a/packages/ui/src/components/Plot/README.md b/packages/ui/src/components/Plot/README.md new file mode 100644 index 000000000..8710a676e --- /dev/null +++ b/packages/ui/src/components/Plot/README.md @@ -0,0 +1,256 @@ + +# TO DO + +- scales: + - allow `scales` to be function which takes the `data` prop (or passed down data) and returns an object so that can use the data to compoute the scales + - shorthand for linear scales: e.g. `x={[10, 40]}`. + - test with discrete scales +- remove `strokeCap` from `Circle` +- change to `values` as an accessor for things that consume tick values - treating as data is unintuitive even if is more powerful +- adapting to width changes - even if just 'redraw' the plot - prob with debounce +- tooltip: + - allow any action when trigger selected so can eg show a MUI tooltip +- making \ accept children is wrong/misleading since is adding contents SVG not HTML elemnt + - how do e.g. subscript? - could make it a foreign object and use HTML? +- add marks +- implement `clip` prop on a mark to clip it to the panel - see https://stackoverflow.com/questions/17388689/svg-clippath-and-transformations +- pass index to accessor functions - and all data values? +- have not implemented `panelSize` prop? +- if error because no `MapX` or `mapY` is it clear that missing scale is the reason? + +-------- + +## Vis Provider + +If drawing multiple related plots, wrap them in a ``. This provides a single location where data for the plots can be added. + +A vis provider is also required for interactive plots - even a single interactive plot. For multi-plot visualisations, the vis provider makes it easy to e.g. hover on a point in one plot and highlight the corresponding points in the other plots. It's also fine to include non-plot content in the provider - this content can import and use the context provided by `VisProvider` like any other React context. + +> Note: CSS should be used to layout groups of plots - flex or grid is typically the most useful. + +## Plot + +An individual plot is created with the `` component. This creates its own context which makes the plot's data and options available to components inside the plot. + +## Frame + +Use a `Frame` to use different scales (for the same channel) on the same plot. + +Frames allow us to overlay multiple plots on the same panel. A frame goes inside a plot element but can take its `data`, `scales`, `xTick`, `yTick`, `xReverse` and `yReverse` props - where used, these override those inherited from the plot. + +A frame can contain any of the components that a plot can contain - except for another frame. + +In the following example, we use a frame to display a second y-axis that shows the weight in pounds (where 1 kg = 2.2 lbs): + +```jsx + + + + Height (cm) + + + Weight (kg) + + + + Weight (lbs) + + d.height_cm} y={d => d.weight_kg} /> + +``` + +### Panel + +Conceptually, a plot contains a reactangular _panel_ where the marks are drawn. Any axes are shown along the side/top/bottom of the panel, but are outside the panel. To style this panel, include a `` component. The following props can be used to style the panel: + +| Prop | Default | +|----------|-------------| +| `background` | `left-aligned` | +| `borderWidth` | `0` | +| `borderColor` | `#888` | + +### Scales + +The `x`, `y`, `fill`, `stroke`, `area` and `shape` channels require _scales_: functions that map values from 'data space' to 'plot space'. A scale should be a [D3 scale](https://d3js.org/d3-scale). The `x` and `y` scales should have domains but not ranges - these are added to the scale based on the panel dimensions. The other scales should have domains and ranges. + +Scales are passed inside a single `scales` prop of `Plot`, e.g. + +```jsx + +``` + +Notes: + +- If a D3 scale method is passed a single argument (e.g. `d3.scaleLinear([0, 100])`), the argument is interpreted as the range - use the `domain` method as in the example above to only specify a domain. + +- The domain used for the x and y scales determines the x and y limits of the plot. + +- The `xx` and `width` channels use the `x` scale. The `yy` and `height` channels use the `y` scale. + +- If different scales for the same channel are required within the same plot, frames can be used to overlay plots. + +- Channels not discussed in this section do not require scales - values in 'plot space' are used directly. + +- To use a flipped `x` scale that increases from right to left use the `xReverse` prop - it need not be given a value. Similarly, use `yReverse` for a flipped `y` scale. + +### Ticks + +There is an `XTick` component for creating and rendering the x ticks. Tick values can be passed to the `Plot` using the `xTick` prop. These are passed to `XTick`, `XGrid`, `XLabel` as the default of the `values` prop. + +If the `xTick` prop of `Plot` is not used, the default values are automatically created from the x scale. + +> Note: The `values` prop of the `XTick`, `XGrid`, `XLabel` can be a function to transform the values passed down from the plot or frame - THIS IS LIKELY TO CHANGE!! + +The behavior for y ticks is identical to that of x ticks. + +## Data + +Data flows through a visualisation `VisProvider` -> `Plot` -> `Frame` -> plot components. In many cases, `VisProvider` and `Frame` are omitted. + +Each of `VisProvider`, `Plot`, `Frame` as well as mark components such as `Circle` can take a `data` prop. This can be a data set or a function. A function is passed the data from the component above and should return a transformed data set for the component. + +If the `data` prop is not used, data is passed down from the parent component as is. + +> Note: The data used by mark components such as `` must be an iterable. A `` can still use data passed down to it that is not iterable, but the `data` prop must be used to transform the data into an iterable. + + +## Marks + +### Multi marks + +__NOT ALL IMPLEMENTED__ + +One mark per data point: + +| Mark | Description | +|-----------|-------------| +| circle | circles | +| point | points | +| hBar | horizontal bars | +| vBar | vertical bars | +| rect | rectangles | +| arc | circular/annular sectors | +| segment | line segments | +| hLink | horizontal links | +| vLink | vertical links | +| edge | circular edges | +| text | text | +| path | path | + +### Single mark for multiple data points: + +__NOT IMPLEMENTED__ + +| Mark | Description | +|---------|-------------| +| line | line | +| hBand | horizontal band | +| vBand | vertical band | + +## Channels + +_Accessor functions_ are used to map data to channels. Each data point is passed to the accessor function along with its index; the function returns the value for that channel. Accessor functions are often very simple, e.g. `x={d => d.year}`. + +[???] For single marks such as lines, all channels except `x`, `xx`, `y`, `yy` must be constant. Accessor functions can still be used for these constant channels, but each function is only called once with arguments `null`, `null`, `data`. When an accessor is used, the channel is still constant in that the channel default is used if the accessor returns `null` or `undefined`. + +### Constants + +To set a channel value (e.g. `x` or `fill`) to be constant use an object as the channel prop, e.g. + +```jsx + d.cost} // use "cost" property of data + y={{ input: 10 }} // constant y value + fill={{ output: 'red' }} // constant fill value +/> +``` + +For channels that rely on a scale, use the `input` or `output` property to indicate if the constant value should be scaled (`input`) or used as-is (`output`). For channels such as `markDash` that do not rely on a scale, use the `output` property. + +For convenience, we can pass a number or string (the constant value) rather than an object. For channels that use the `x` or `y` scale, the constant is interpreted as an 'input' - i.e. the constant will be scaled. For all other channels, a number or string is interpreted as an output. Using this shorthand, the above example can be written as: + +```jsx + d.cost} // use "cost" property of data + y={10} // the y scale will be used to to get the actual y position + fill="red" // fill will be red - the fill scale will not be used +/> +``` + +To draw a single mark with all constant channels, use a data set of length 1: + +```jsx + +``` + +------ + +Notes: +- left out YTitle for now since rarely use old school rotated y axis title anymore. Can use an + XTitle with position='top' and textAnchor='end' +- currently always using indices for keys - may need to revisit this when think about animation, interaction, ... +- we can pass arbitrary attr values to ticks, labels etc, but not to marks - since all 'other props' are interpreted as channels. Can/should we allow passing arb attr values through to the svg element representing the mark? + +Add to docs above +- padding (on axis, ticks, ...) pushes them away from panel whereas dx,dy props are always in pixels and +ve x to right, +ve y downwards +- use e.g. `stroke` to change color in `XTick` - even though there is `tickColor` in defaults, this is not a prop + +POSSIBLE!!: +- border and cornerradius for the plot? - just as have for the panel +- ? HTML inserts - for tooltip, titles, insets, ...? + - could have an HTML mark? +- specify panel size, plot size or container size +- optional HTML wrapper the size of the container. Absolute poistioned and optional z-index. Useful for titles and text in some cases +- rely heavily on accessor functions +- faceting - this will prob limit the min x and y grid squares, but we should still be able to make the grid larger to include other plots - or overlay plots +- give elements classes so easily styled? +- conveinernce components to add xAxis, ticks, labels and title altogether +- currently keep all options values for components that have contexts in the context. However, really only need the options that may be used by descendent components in the context. +- Allow the data prop of a `Vis` component to be an asynchronous function where the returned promise resolves to the data. + - important for allowing skeleton and avoiding layout shift +- transitions +- allow more props in frames? - so can e.g. override channel defaults of plot +- allow more flexibility with data structure? - e.g. column-based data? +- should `missing` be at plot and frame level rather than just mark level? +- have e.g. a `constant` prop in marks so can avoid the hacky `data={[1]}` to draw a single mark when all props are constants +- do not have same channel defaults for all marks? - annoying that need to set `strokeWidth` to see lines +- custom marks - e.g.custom shapes or images for points. +- since data can be any iterable, should also allow tick values (when actually used since can be transformed by `values`) to be any iterable rather than just an array +- end channels: front, facet (or row/column), ... + +## Examples/Tests + +- use `data` of mark to filter data +- multuple y-axis +- use Frame for inlaid subplot? +- have a before or after the axis title by using e.g. position="right", + overriding the textAnchor and using dx and dy +- rotated labels, in this case x labels at bottom: + + ```jsx + String(i).repeat(i + 1)} + textAnchor="end" + style={{ + transformOrigin: '100% 50%', + transformBox: 'fill-box', + transform: "rotate(-45deg)", + }} + /> + ``` \ No newline at end of file diff --git a/packages/ui/src/components/Plot/TestPlot.jsx b/packages/ui/src/components/Plot/TestPlot.jsx new file mode 100644 index 000000000..00eb5d2c1 --- /dev/null +++ b/packages/ui/src/components/Plot/TestPlot.jsx @@ -0,0 +1,47 @@ +import { VisProvider } from "./contexts/VisContext"; +import Plot from "./components/Plot" +import Panel from "./components/Panel"; +import XAxis from "./components/XAxis"; +import YAxis from "./components/YAxis"; +import Frame from "./components/Frame"; +import XTick from "./components/XTick"; +import YTick from "./components/YTick"; +import XLabel from "./components/XLabel"; +import * as d3 from "d3"; +import YLabel from "./components/YLabel"; +import XTitle from "./components/XTitle"; +import XGrid from "./components/XGrid"; +import YGrid from "./components/YGrid"; +import Circle from "./components/marks/Circle"; + +const data = [[0, 0], [25, 50], [50, 100]]; + +export default function TestPlot() { + return ( + // + + + + {/* ticks.map(t => t - 2)}/> */} + + + + + + + d[1]} /> + + // + ); +} \ No newline at end of file diff --git a/packages/ui/src/components/Plot/components/Frame.jsx b/packages/ui/src/components/Plot/components/Frame.jsx new file mode 100644 index 000000000..709bd8c4e --- /dev/null +++ b/packages/ui/src/components/Plot/components/Frame.jsx @@ -0,0 +1,19 @@ +import { FrameProvider, useFrame } from "../contexts/FrameContext"; +import { usePlot } from "../contexts/PlotContext"; + +export default function Frame({ children, options }) { + const plot = usePlot(); + if (!plot) { + throw Error("Frame component must appear inside a Plot component"); + } + const outerFrame = useFrame(); + if (outerFrame) { + throw Error ('Frame components cannot be nested'); + } + + return ( + + {children} + + ); +} \ No newline at end of file diff --git a/packages/ui/src/components/Plot/components/Panel.jsx b/packages/ui/src/components/Plot/components/Panel.jsx new file mode 100644 index 000000000..d02e17406 --- /dev/null +++ b/packages/ui/src/components/Plot/components/Panel.jsx @@ -0,0 +1,22 @@ +import { usePlot } from "../contexts/PlotContext"; + +// use rectAttrs for e.g. fill, stroke, strokeWidth, rx, ... +export default function Panel(rectAttrs) { + const plot = usePlot(); + if (!plot) { + throw Error("Panel component must appear inside a Plot component"); + } + + + const { panelWidth, panelHeight, padding} = plot; + + return ( + + ); +} \ No newline at end of file diff --git a/packages/ui/src/components/Plot/components/Plot.jsx b/packages/ui/src/components/Plot/components/Plot.jsx new file mode 100644 index 000000000..dfce02d2f --- /dev/null +++ b/packages/ui/src/components/Plot/components/Plot.jsx @@ -0,0 +1,12 @@ +import { PlotProvider } from "../contexts/PlotContext"; +import SVGContainer from "./SVGContainer" + +export default function Plot({ children, ...options }) { + return ( + + + {children} + + + ); +} \ No newline at end of file diff --git a/packages/ui/src/components/Plot/components/SVGContainer.jsx b/packages/ui/src/components/Plot/components/SVGContainer.jsx new file mode 100644 index 000000000..7b447aa2c --- /dev/null +++ b/packages/ui/src/components/Plot/components/SVGContainer.jsx @@ -0,0 +1,35 @@ +import { usePlot } from "../contexts/PlotContext"; + +export default function SVGContainer({ children }) { + const plot = usePlot(); + if (!plot) { + throw Error("SVGContainer component must appear inside a Plot component"); + } + + const { width, height, background, cornerRadius } = plot; + + return ( + {(background !== 'transparent' || cornerRadius > 0) && + + } + {children} + + ); +} \ No newline at end of file diff --git a/packages/ui/src/components/Plot/components/XAxis.jsx b/packages/ui/src/components/Plot/components/XAxis.jsx new file mode 100644 index 000000000..1626460aa --- /dev/null +++ b/packages/ui/src/components/Plot/components/XAxis.jsx @@ -0,0 +1,27 @@ +import { usePlot } from "../contexts/PlotContext"; + +export default function XAxis({ position = 'bottom', padding, ...lineAttrs }) { + + const plot = usePlot(); + if (!plot) { + throw Error("XAxis component must appear inside a Plot component"); + } + + padding ??= plot.axisPadding; + + const y = position === 'top' + ? plot.padding.top - padding + : plot.height - plot.padding.bottom + padding; + + return ( + + ); +} \ No newline at end of file diff --git a/packages/ui/src/components/Plot/components/XGrid.jsx b/packages/ui/src/components/Plot/components/XGrid.jsx new file mode 100644 index 000000000..037f63fc4 --- /dev/null +++ b/packages/ui/src/components/Plot/components/XGrid.jsx @@ -0,0 +1,44 @@ +import { usePlot } from "../contexts/PlotContext"; +import { useFrame } from "../contexts/FrameContext"; +import { fromFrameOrPlot } from "../util/fromFrameOrPlot"; +import { finalData } from "../util/finalData"; + +export default function XGrid({ values, ...lineAttrs }) { + + const plot = usePlot(); + if (!plot) { + throw Error("XGrid component must appear inside a Plot component"); + } + const frame = useFrame(); + + const ops = fromFrameOrPlot(['xTick', 'scales', 'xReverse'], frame, plot); + + const tickValues = finalData(ops.xTick, values); + if (!tickValues) return null; + + const xScale = ops.xReverse + ? v => plot.panelWidth - ops.scales.x(v) + : ops.scales.x; + + const leftOrigin = `translate(${plot.padding.left},${plot.padding.top})`; + + return ( + + {tickValues.map((v, i) => { + const x = xScale(v); + return ( + + ); + })} + + ); +} \ No newline at end of file diff --git a/packages/ui/src/components/Plot/components/XLabel.jsx b/packages/ui/src/components/Plot/components/XLabel.jsx new file mode 100644 index 000000000..c65130898 --- /dev/null +++ b/packages/ui/src/components/Plot/components/XLabel.jsx @@ -0,0 +1,63 @@ +import { usePlot } from "../contexts/PlotContext"; +import { useFrame } from "../contexts/FrameContext"; +import { fromFrameOrPlot } from "../util/fromFrameOrPlot"; +import { finalData } from "../util/finalData"; + +export default function XLabel({ + values, + position = 'bottom', + padding, + dx = 0, + dy = 0, + format, + ...textAttrs + }) { + + const plot = usePlot(); + if (!plot) { + throw Error("XLabel component must appear inside a Plot component"); + } + const frame = useFrame(); + + const ops = fromFrameOrPlot(['xTick', 'scales', 'xReverse'], frame, plot); + + const tickValues = finalData(ops.xTick, values); + if (!tickValues) return null; + + padding ??= plot.labelPadding; + + const xScale = ops.xReverse + ? v => plot.panelWidth - ops.scales.x(v) + : ops.scales.x; + + const leftOrigin = `translate(${ + plot.padding.left + dx},${ + position === 'top' + ? plot.padding.top - padding + dy + : plot.height - plot.padding.bottom + padding + dy + })`; + + return ( + + {tickValues.map((v, i) => { + return ( + + {format ? format(v, i, tickValues, ops.xTick) : v} + + ); + })} + + ); +} \ No newline at end of file diff --git a/packages/ui/src/components/Plot/components/XTick.jsx b/packages/ui/src/components/Plot/components/XTick.jsx new file mode 100644 index 000000000..0ff27abaa --- /dev/null +++ b/packages/ui/src/components/Plot/components/XTick.jsx @@ -0,0 +1,60 @@ +import { usePlot } from "../contexts/PlotContext"; +import { useFrame } from "../contexts/FrameContext"; +import { fromFrameOrPlot } from "../util/fromFrameOrPlot"; +import { finalData } from "../util/finalData"; + +export default function XTick({ + values, + position = 'bottom', + padding, + tickLength, + ...lineAttrs + }) { + + const plot = usePlot(); + if (!plot) { + throw Error("XTick component must appear inside a Plot component"); + } + const frame = useFrame(); + + const ops = fromFrameOrPlot(['xTick', 'scales', 'xReverse'], frame, plot); + + padding ??= plot.tickPadding; + + const tickValues = finalData(ops.xTick, values); + if (!tickValues) return null; + + const xScale = ops.xReverse + ? v => plot.panelWidth - ops.scales.x(v) + : ops.scales.x; + + const leftOrigin = `translate(${ + plot.padding.left},${ + position === 'top' + ? plot.padding.top - padding + : plot.height - plot.padding.bottom + padding + })`; + + tickLength ??= plot.tickLength; + const y2 = position === 'top' ? -tickLength : tickLength; + + return ( + + {tickValues.map((v, i) => { + const x = xScale(v); + return ( + + ); + })} + + ); +} \ No newline at end of file diff --git a/packages/ui/src/components/Plot/components/XTitle.jsx b/packages/ui/src/components/Plot/components/XTitle.jsx new file mode 100644 index 000000000..a26241f4f --- /dev/null +++ b/packages/ui/src/components/Plot/components/XTitle.jsx @@ -0,0 +1,62 @@ +import { usePlot } from "../contexts/PlotContext"; + +export default function XTitle({ + children, + position = 'bottom', + align = 'center', // 'left', 'center' or 'right' + padding, + dx = 0, + dy = 0, + ...textAttrs // be very careful if change the transform-related CSS props + // used in the element + }) { + + const plot = usePlot(); + if (!plot) { + throw Error("XTitle component must appear inside a Plot component"); + } + + if (!children) return null; + + padding ??= plot.titlePadding; + + let x, textAnchor; + if (align === 'left') { + x = plot.padding.left; + textAnchor = 'start'; + } else if (align === 'right') { + x = plot.width - plot.padding.right; + textAnchor = 'end'; + } else { + x = plot.padding.left + plot.panelWidth / 2; + textAnchor = 'middle'; + } + x += dx; + + let y, alignmentBaseline; + if (position === 'top') { + y = plot.padding.top - padding; + alignmentBaseline = 'baseline'; + } else { + y = plot.height - plot.padding.bottom + padding; + alignmentBaseline = 'hanging'; + } + y += dy; + + return ( + + {children} + + ); +} \ No newline at end of file diff --git a/packages/ui/src/components/Plot/components/YAxis.jsx b/packages/ui/src/components/Plot/components/YAxis.jsx new file mode 100644 index 000000000..285e98ebb --- /dev/null +++ b/packages/ui/src/components/Plot/components/YAxis.jsx @@ -0,0 +1,27 @@ +import { usePlot } from "../contexts/PlotContext"; + +export default function YAxis({ position = 'left', padding, ...lineAttrs }) { + + const plot = usePlot(); + if (!plot) { + throw Error("YAxis component must appear inside a Plot component"); + } + + padding ??= plot.axisPadding; + + const x = position === 'right' + ? plot.width - plot.padding.right + padding + : plot.padding.left - padding; + + return ( + + ); +} \ No newline at end of file diff --git a/packages/ui/src/components/Plot/components/YGrid.jsx b/packages/ui/src/components/Plot/components/YGrid.jsx new file mode 100644 index 000000000..31f5f0ab4 --- /dev/null +++ b/packages/ui/src/components/Plot/components/YGrid.jsx @@ -0,0 +1,44 @@ +import { usePlot } from "../contexts/PlotContext"; +import { useFrame } from "../contexts/FrameContext"; +import { fromFrameOrPlot } from "../util/fromFrameOrPlot"; +import { finalData } from "../util/finalData"; + +export default function YGrid({ values, ...lineAttrs }) { + + const plot = usePlot(); + if (!plot) { + throw Error("YGrid component must appear inside a Plot component"); + } + const frame = useFrame(); + + const ops = fromFrameOrPlot(['yTick', 'scales', 'yReverse'], frame, plot); + + const tickValues = finalData(ops.yTick, values); + if (!tickValues) return null; + + const yScale = ops.yReverse + ? ops.scales.y + : v => plot.panelHeight - ops.scales.y(v); + + const topOrigin = `translate(${plot.padding.left},${plot.padding.top})`; + + return ( + + {tickValues.map((v, i) => { + const y = yScale(v); + return ( + + ); + })} + + ); +} \ No newline at end of file diff --git a/packages/ui/src/components/Plot/components/YLabel.jsx b/packages/ui/src/components/Plot/components/YLabel.jsx new file mode 100644 index 000000000..46a3defcf --- /dev/null +++ b/packages/ui/src/components/Plot/components/YLabel.jsx @@ -0,0 +1,63 @@ +import { usePlot } from "../contexts/PlotContext"; +import { useFrame } from "../contexts/FrameContext"; +import { fromFrameOrPlot } from "../util/fromFrameOrPlot"; +import { finalData } from "../util/finalData"; + +export default function YLabel({ + values, + position = 'left', + padding, + dx = 0, + dy = 0, + format, + ...textAttrs + }) { + + const plot = usePlot(); + if (!plot) { + throw Error("YLabel component must appear inside a Plot component"); + } + const frame = useFrame(); + + const ops = fromFrameOrPlot(['yTick', 'scales', 'yReverse'], frame, plot); + + const tickValues = finalData(ops.yTick, values); + if (!tickValues) return null; + + padding ??= plot.labelPadding; + + const yScale = ops.yReverse + ? ops.scales.y + : v => plot.panelHeight - ops.scales.y(v); + + const topOrigin = `translate(${ + position === 'right' + ? plot.width - plot.padding.right + padding + dx + : plot.padding.left - padding + dx},${ + plot.padding.top + dy + })`; + + return ( + + {tickValues.map((v, i) => { + return ( + + {format ? format(v, i, tickValues, ops.yTick) : v} + + ); + })} + + ); +} \ No newline at end of file diff --git a/packages/ui/src/components/Plot/components/YTick.jsx b/packages/ui/src/components/Plot/components/YTick.jsx new file mode 100644 index 000000000..49998612f --- /dev/null +++ b/packages/ui/src/components/Plot/components/YTick.jsx @@ -0,0 +1,61 @@ +import { usePlot } from "../contexts/PlotContext"; +import { useFrame } from "../contexts/FrameContext"; +import { fromFrameOrPlot } from "../util/fromFrameOrPlot"; +import { finalData } from "../util/finalData"; + +export default function YTick({ + values, + position = 'left', + padding, + tickLength, + ...lineAttrs + }) { + + const plot = usePlot(); + if (!plot) { + throw Error("YTick component must appear inside a Plot component"); + } + const frame = useFrame(); + + const ops = + fromFrameOrPlot(['yTick', 'scales', 'yReverse'], frame, plot); + + padding ??= plot.tickPadding; + + const tickValues = finalData(ops.yTick, values); + if (!tickValues) return null; + + const yScale = ops.yReverse + ? ops.scales.y + : v => plot.panelHeight - ops.scales.y(v); + + const topOrigin = `translate(${ + position === 'right' + ? plot.width - plot.padding.right + padding + : plot.padding.left - padding},${ + plot.padding.top + })`; + + tickLength ??= plot.tickLength; + const x2 = position === 'right' ? tickLength : -tickLength; + + return ( + + {tickValues.map((v, i) => { + const y = yScale(v); + return ( + + ); + })} + + ); +} \ No newline at end of file diff --git a/packages/ui/src/components/Plot/components/marks/Circle.jsx b/packages/ui/src/components/Plot/components/marks/Circle.jsx new file mode 100644 index 000000000..6ced865c5 --- /dev/null +++ b/packages/ui/src/components/Plot/components/marks/Circle.jsx @@ -0,0 +1,86 @@ +import { usePlot } from "../../contexts/PlotContext"; +import { useFrame } from "../../contexts/FrameContext"; +import { fromFrameOrPlot } from "../../util/fromFrameOrPlot"; +import { isIterable } from "../../util/helpers"; +import { finalData } from "../../util/finalData"; +import { processAccessors } from "../../util/processAccessors"; +import { rowValues } from "../../util/rowValues"; + +export default function Circle({ data, missing = 'throw', ...accessors }) { + + const plot = usePlot(); + if (!plot) { + throw Error("Circle component must appear inside a Plot component"); + } + const frame = useFrame(); + + const ops = fromFrameOrPlot(['data', 'scales', 'mapX', 'mapY'], frame, plot); + const { scales, mapX, mapY } = ops; + + data = finalData(ops.data, data); + if (!isIterable(data)) { + throw Error('mark data must be an iterable'); + } + + const finalAccessors = processAccessors({ + markChannels: [ + 'x', + 'y', + 'dx', + 'dy', + 'fill', + 'fillOpacity', + 'stroke', + 'strokeOpacity', + 'strokeWidth', + 'strokeCap', + 'strokeDash', + 'area', + ], + accessors, + scales, + mapX, + mapY, + }); + + const marks = []; + + for (const d of data) { + + const row = rowValues({ + rowData: d, + missing, + finalAccessors, + scales, + mapX, + mapY, + }); + + if (row != null) { + const attrs = { + cx: row.x + row.dx, + cy: row.y + row.dy, + r: Math.sqrt(row.area / Math.PI), + fill: row.fill, + fillOpacity: row.fillOpacity, + stroke: row.stroke, + strokeOpacity: row.strokeOpacity, + strokeWidth: row.strokeWidth, + }; + if (row.strokeCap) attrs.strokeCap = row.strokeCap; + if (row.strokeDash) attrs.strokeDash = row.strokeDash; + marks.push( + + ); + } + } + + if (marks.length === 0) return null; + + return ( + + {marks} + + ); + +} \ No newline at end of file diff --git a/packages/ui/src/components/Plot/components/marks/Segment.jsx b/packages/ui/src/components/Plot/components/marks/Segment.jsx new file mode 100644 index 000000000..c089549a4 --- /dev/null +++ b/packages/ui/src/components/Plot/components/marks/Segment.jsx @@ -0,0 +1,84 @@ +import { usePlot } from "../../contexts/PlotContext"; +import { useFrame } from "../../contexts/FrameContext"; +import { fromFrameOrPlot } from "../../util/fromFrameOrPlot"; +import { isIterable } from "../../util/helpers"; +import { finalData } from "../../util/finalData"; +import { processAccessors } from "../../util/processAccessors"; +import { rowValues } from "../../util/rowValues"; + +export default function Segment({ data, missing = 'throw', ...accessors }) { + + const plot = usePlot(); + if (!plot) { + throw Error("Segment component must appear inside a Plot component"); + } + const frame = useFrame(); + + const ops = fromFrameOrPlot(['data', 'scales', 'mapX', 'mapY'], frame, plot); + const { scales, mapX, mapY } = ops; + + data = finalData(ops.data, data); + if (!isIterable(data)) { + throw Error('mark data must be an iterable'); + } + + const finalAccessors = processAccessors({ + markChannels: [ + 'x', + 'xx', + 'y', + 'yy', + 'dx', + 'dy', + 'stroke', + 'strokeOpacity', + 'strokeWidth', + 'strokeCap', + 'strokeDash', + ], + accessors, + scales, + mapX, + mapY, + }); + + const marks = []; + + for (const d of data) { + + const row = rowValues({ + rowData: d, + missing, + finalAccessors, + scales, + mapX, + mapY, + }); + + if (row != null) { + const attrs = { + x1: row.x + row.dx, + y1: row.y + row.dy, + x2: row.xx + row.dx, + y2: row.yy + row.dy, + stroke: row.stroke, + strokeOpacity: row.strokeOpacity, + strokeWidth: row.strokeWidth, + }; + if (row.strokeCap) attrs.strokeCap = row.strokeCap; + if (row.strokeDash) attrs.strokeDash = row.strokeDash; + marks.push( + + ); + } + } + + if (marks.length === 0) return null; + + return ( + + {marks} + + ); + +} \ No newline at end of file diff --git a/packages/ui/src/components/Plot/contexts/FrameContext.jsx b/packages/ui/src/components/Plot/contexts/FrameContext.jsx new file mode 100644 index 000000000..ed07ec6d8 --- /dev/null +++ b/packages/ui/src/components/Plot/contexts/FrameContext.jsx @@ -0,0 +1,62 @@ + +import { createContext, useContext, useReducer } from 'react'; +import { usePlot } from './PlotContext'; +import { finalData } from '../util/finalData'; +import { addXYMaps } from '../util/addXYMaps'; +import { onlyValidScales } from '../util/assert'; + +const FrameContext = createContext(null); +const FrameDispatchContext = createContext(null); + +export function FrameProvider({ + children, + data, + scales = {}, + xTick, + yTick, + xReverse, + yReverse, + }) { + + const plot = usePlot(); + + const initialState = { data, scales, xTick, yTick, xReverse, yReverse }; + initialState.data = finalData(plot.data, initialState.data); + onlyValidScales(scales); + scales.x?.range?.([0, plot.width]); + scales.y?.range?.([0, plot.height]); + addXYMaps(initialState); + if (!xTick && scales.x) { + initialState.xTick = scales.x?.ticks?.() ?? scales.x?.domain(); + } + if (!yTick && scales.y) { + initialState.yTick = scales.y?.ticks?.() ?? scales.y.domain(); + } + for (let [key, value] of Object.entries(plot.scales)) { + scales[key] ??= value; + } + + const [state, stateDispatch] = useReducer(reducer, initialState); + + return ( + + + {children} + + + ); +} + +// export hooks +export function useFrame() { + return useContext(FrameContext); +} + +export function useFrameDispatch() { + return useContext(FrameDispatchContext); +} + +// data reducer +function reducer(state, action) { + +} \ No newline at end of file diff --git a/packages/ui/src/components/Plot/contexts/PlotContext.jsx b/packages/ui/src/components/Plot/contexts/PlotContext.jsx new file mode 100644 index 000000000..2a6c44912 --- /dev/null +++ b/packages/ui/src/components/Plot/contexts/PlotContext.jsx @@ -0,0 +1,58 @@ + +import { createContext, useContext, useReducer } from 'react'; +import { useVis } from './VisContext'; +import { safeAssign } from '../util/helpers'; +import { finalData } from '../util/finalData'; +import { plotDefaults } from '../defaults/plotDefaults'; +import { addXYMaps } from '../util/addXYMaps'; +import { onlyValidScales } from '../util/assert'; + +const PlotContext = createContext(null); +const PlotDispatchContext = createContext(null); + +export function PlotProvider({ children, options }) { + + const vis = useVis(); + const initialState = safeAssign({ ...plotDefaults }, options); + initialState.data = finalData(vis?.data, initialState.data); + + // compute values related to plot and panel spacing + let { padding } = initialState; + if (typeof padding === 'number') { + padding = { top: padding, right: padding, bottom: padding, left: padding }; + initialState.padding = padding; + } + initialState.panelWidth = initialState.width - padding.left - padding.right; + initialState.panelHeight = initialState.height - padding.top - padding.bottom; + const { scales } = initialState; + onlyValidScales(scales); + scales.x?.range?.([0, initialState.panelWidth]); + scales.y?.range?.([0, initialState.panelHeight]); + addXYMaps(initialState); + initialState.xTick ??= scales.x?.ticks?.() ?? scales.x?.domain(); + initialState.yTick ??= scales.y?.ticks?.() ?? scales.y?.domain(); + + const [state, stateDispatch] = useReducer(reducer, initialState); + + return ( + + + {children} + + + ); +} + +// export hooks +export function usePlot() { + return useContext(PlotContext); +} + +export function usePlotDispatch() { + return useContext(PlotDispatchContext); +} + +// data reducer +function reducer(state, action) { + +} \ No newline at end of file diff --git a/packages/ui/src/components/Plot/contexts/VisContext.jsx b/packages/ui/src/components/Plot/contexts/VisContext.jsx new file mode 100644 index 000000000..d464e6b01 --- /dev/null +++ b/packages/ui/src/components/Plot/contexts/VisContext.jsx @@ -0,0 +1,52 @@ + +import { createContext, useContext, useReducer } from 'react'; + +const VisContext = createContext(null); +const VisDispatchContext = createContext(null); + +export function VisProvider({ children, data = null }) { + + const initialState = { + data, + selected: {}, // individual points that are selected + }; + + const [state, stateDispatch] = useReducer(reducer, initialState); + + return ( + + + {children} + + + ); +} + +export function useVis() { + return useContext(VisContext); +} + +export function useVisDispatch() { + return useContext(VisDispatchContext); +} + +// data reducer +function reducer(state, action) { + + switch(action.type) { + + // case 'select': { + // const newState = {}; + // newState.selected[action.] = action.data; + // } + // } + + } + + + + // !! IF ACTION REPLACES DATA, SET selected TO {} - AND ANY OTHER + // INTERACTION IBJECTS THAT INTRODUCE + + return newState; +} \ No newline at end of file diff --git a/packages/ui/src/components/Plot/defaults/channelDefaults.js b/packages/ui/src/components/Plot/defaults/channelDefaults.js new file mode 100644 index 000000000..0d4212181 --- /dev/null +++ b/packages/ui/src/components/Plot/defaults/channelDefaults.js @@ -0,0 +1,40 @@ +export const channelDefaults = { + + // common + x: 0, + y: 0, + dx: 0, // pixels + dy: 0, // pixels + fill: '#000', + fillOpacity: '0.7', + stroke: '#000', + strokeOpacity: 1, + strokeWidth: 0, // !! MUST SET STROKE WIDTHS TO SEE LINES !! + strokeCap: null, + strokeDash: null, + + // special - only used by one/few marks + xx: 0, // HBar, Segment, HLink, VLink, Edge, VBand + yy: 0, // VBar, Segment, HLink, VLink, Edge, HBand + shape: 'circle', // Point + area: 36, // Circle, Point + cornerRadius: null, // HBar, VBar, Rect + width: 1, // VBar, Rect (x units) + height: 1, // HBar, Rect (y units) + pxWidth: 1, // VBar, Rect (pixels) + pxHeight: 1, // HBar, Rect (pixels) + tension: 0.5, // Edge + clockwise: true, // Edge + path: null, // Path + text: '', // Text + fontFamily: 'sans-serif', // Text + fontSize: '11px', // Text + fontStyle: 'normal', // Text + fontWeight: 'normal', // Text + textAnchor: 'middle', // Text + dominantBaseline: 'middle', // Text + transformOrigin: 'center', // Text (CSS) + transformBox: 'fill-box', // Text (CSS) + transform: null, // Text (CSS) + +}; \ No newline at end of file diff --git a/packages/ui/src/components/Plot/defaults/plotDefaults.js b/packages/ui/src/components/Plot/defaults/plotDefaults.js new file mode 100644 index 000000000..4415c67e5 --- /dev/null +++ b/packages/ui/src/components/Plot/defaults/plotDefaults.js @@ -0,0 +1,36 @@ +export const plotDefaults = { + + data: null, + scales: {}, + xReverse: false, + yReverse: false, + width: 260, + height: 260, + setPanelSize: false, + padding: 40, // number or object with props 'top', 'left', 'bottom', 'right' + background: 'transparent', + cornerRadius: 0, + + // font defaults for labels and titles - not used by marks + fontFamily: 'sans-serif', + fontSize: '11px', + fontStyle: 'normal', + fontWeight: 'normal', + fontColor: '#000', + + // axis, titles, ticks, labels + axisPadding: 0, // axis, tick, label and title padding is from edge of panel + axisColor: '#888', + axisWidth: 1, + tickPadding: 0, + tickColor: '#888', + tickLength: 5, + tickWidth: 1, + labelPadding: 7, + titlePadding: 24, + gridColor: '#ddd', + gridWidth: 1, + xTick: null, + yTick: null, + +}; \ No newline at end of file diff --git a/packages/ui/src/components/Plot/index.js b/packages/ui/src/components/Plot/index.js new file mode 100644 index 000000000..d1910f9bd --- /dev/null +++ b/packages/ui/src/components/Plot/index.js @@ -0,0 +1,16 @@ +export { VisProvider, useVis, useVisDispatch } from "./contexts/VisContext"; +export { default as Frame } from "./components/XAxis"; +export { default as Panel } from "./components/Panel"; +export { default as Plot } from "./components/Plot"; +export { default as SVGContainer } from "./components/SVGContainer"; +export { default as XAxis } from "./components/XAxis"; +export { default as XGrid } from "./components/XGrid"; +export { default as XLabel } from "./components/XLabel"; +export { default as XTick } from "./components/XTick"; +export { default as XTitle } from "./components/XTitle"; +export { default as YAxis } from "./components/YAxis"; +export { default as YGrid } from "./components/YGrid"; +export { default as YLabel } from "./components/YLabel"; +export { default as YTick } from "./components/YTick"; +export { default as Circle } from "./components/marks/Circle"; +export { default as Segment } from "./components/marks/Segment"; \ No newline at end of file diff --git a/packages/ui/src/components/Plot/util/addXYMaps.js b/packages/ui/src/components/Plot/util/addXYMaps.js new file mode 100644 index 000000000..594ffd2d0 --- /dev/null +++ b/packages/ui/src/components/Plot/util/addXYMaps.js @@ -0,0 +1,17 @@ +export function addXYMaps(state) { + + const { scales } = state; + + if (scales.x) { + state.mapX = state.xReverse + ? v => state.panelWidth - scales.x(v) + : scales.x; + } + + if (scales.y) { + state.mapY = state.yReverse + ? scales.y + : v => state.panelHeight - scales.y(v); + } + +} \ No newline at end of file diff --git a/packages/ui/src/components/Plot/util/assert.js b/packages/ui/src/components/Plot/util/assert.js new file mode 100644 index 000000000..9a52ab9fb --- /dev/null +++ b/packages/ui/src/components/Plot/util/assert.js @@ -0,0 +1,16 @@ +import { validScales } from '../util/scaleChannels'; +import { isInfiniteOrNaN } from './helpers'; + +export function noInfiniteOrNaN(value, channel) { + if (isInfiniteOrNaN(value)) { + throw Error(`invalid value: ${value}, (channel: ${channel})`); + } +} + +export function onlyValidScales(scales) { + for (let channel of Object.keys(scales)) { + if (!validScales.has(channel)) { + throw Error(`unexpected scale: ${channel}`); + } + } +} \ No newline at end of file diff --git a/packages/ui/src/components/Plot/util/baseReducer.js b/packages/ui/src/components/Plot/util/baseReducer.js new file mode 100644 index 000000000..03a04d130 --- /dev/null +++ b/packages/ui/src/components/Plot/util/baseReducer.js @@ -0,0 +1,28 @@ +export function baseReducer({ state, action, contextName }) { + + if (!Object.hasOwn(state, action.name)) { + throw Error(`${action.name} is not a valid property for ${contextName}`); + } + + let newState = { ...state }; + + switch(action.type) { + + case 'set': + newState[action.name] = action.value; + break; + + // ?? ARE CLEAR AND UPDATE NEEDED ??/ + // case 'clear': + // newState[action.name] = null; + // break; + + // case 'update': + // newState[action.name] = action.value(newState[action.name]); + // break; + + } + + return newState; + +} \ No newline at end of file diff --git a/packages/ui/src/components/Plot/util/finalData.js b/packages/ui/src/components/Plot/util/finalData.js new file mode 100644 index 000000000..1283db769 --- /dev/null +++ b/packages/ui/src/components/Plot/util/finalData.js @@ -0,0 +1,10 @@ +export function finalData(oldValue, newValue) { + if (newValue == null) return oldValue; + if (typeof newValue === 'function') { + if (oldValue == null) { + throw Error('data to transform is null or undefined'); + } + return newValue(oldValue); + } + return newValue; +} \ No newline at end of file diff --git a/packages/ui/src/components/Plot/util/fromFrameOrPlot.js b/packages/ui/src/components/Plot/util/fromFrameOrPlot.js new file mode 100644 index 000000000..6c5099049 --- /dev/null +++ b/packages/ui/src/components/Plot/util/fromFrameOrPlot.js @@ -0,0 +1,8 @@ +// can omit frame and pass plot as second argument +export function fromFrameOrPlot(keys, frame, plot) { + const o = {}; + for (const key of keys) { + o[key] = frame?.[key] ?? plot?.[key]; + } + return o; +} \ No newline at end of file diff --git a/packages/ui/src/components/Plot/util/helpers.js b/packages/ui/src/components/Plot/util/helpers.js new file mode 100644 index 000000000..32e749d59 --- /dev/null +++ b/packages/ui/src/components/Plot/util/helpers.js @@ -0,0 +1,19 @@ + +// like Object.assign, but only copies to properties that original object +// already has +export function safeAssign(obj, newObj) { + for (let [key, value] of Object.entries(newObj)) { + if (Object.hasOwn(obj, key)) { + obj[key] = value; + } + } + return obj; +} + +export function isIterable(value) { + return value != null && typeof value[Symbol.iterator] === 'function'; +} + +export function isInfiniteOrNaN(value) { + return value === Infinity || value === -Infinity || Number.isNaN(value); +} \ No newline at end of file diff --git a/packages/ui/src/components/Plot/util/processAccessors.js b/packages/ui/src/components/Plot/util/processAccessors.js new file mode 100644 index 000000000..be8890f2c --- /dev/null +++ b/packages/ui/src/components/Plot/util/processAccessors.js @@ -0,0 +1,67 @@ +import { channelDefaults } from "../defaults/channelDefaults"; +import { noInfiniteOrNaN } from "./assert"; +import { scaleChannels } from "./scaleChannels"; +import { scaleValue } from "./scaleValue"; + +const autoScaleChannels = new Set(['x', 'xx', 'width', 'y', 'yy', 'height']); + +export function processAccessors({ + markChannels, + accessors, + scales, + mapX, + mapY, + }) { + + const newAccessors = new Map(); + + for (const channel of markChannels) { + + const acc = accessors[channel]; + + // channel omitted - use default value or ignore channel + if (acc == null) { + const value = channelDefaults[channel]; + if (value != null) { + newAccessors.set(channel, value); + } + + // constant channel + } else if (typeof acc === 'object' || + typeof acc === 'number' || + typeof acc === 'string') { + let input, output; + if (typeof acc === 'object') { + ({ input, output } = acc); + } else { + autoScaleChannels.has(channel) ? (input = acc) : (output = acc); + } + let value = output; + if (input != null) { + if (!scaleChannels.has(channel)) { + throw Error(`cannot use an 'input constant' for ${channel} channel`); + } + value = scaleValue({ input, channel, scales, mapX, mapY}); + } + noInfiniteOrNaN(value); + if (value == null) { + value = channelDefaults[channel]; + } + if (value != null) { + newAccessors.set(channel, value); + } + + // dynamic channel + } else if (typeof acc === 'function') { + newAccessors.set(channel, acc); + + // invalid accessor + } else { + throw Error(`invalid accessor (channel: ${channel})`); + } + + } + + return newAccessors; + +} \ No newline at end of file diff --git a/packages/ui/src/components/Plot/util/rowValues.js b/packages/ui/src/components/Plot/util/rowValues.js new file mode 100644 index 000000000..39cf67b09 --- /dev/null +++ b/packages/ui/src/components/Plot/util/rowValues.js @@ -0,0 +1,35 @@ +import { scaleValue } from "./scaleValue"; +import { noInfiniteOrNaN } from "./assert"; + +export function rowValues({ + rowData, + missing, + finalAccessors, + scales, + mapX, + mapY, + }) { + + const values = {}; + + for (const [channel, accessor] of finalAccessors) { + let value = accessor; + if (typeof accessor === 'function') { + value = accessor(rowData); + noInfiniteOrNaN(value, channel); + } + if (value == null) { + if (missing === 'throw') { + throw Error(`missing value (channel: ${channel})`); + } + return null; + } + if (typeof accessor === 'function') { // constants already scaled + value = scaleValue({ input: value, channel, scales, mapX, mapY }); + } + values[channel] = value; + } + + return values; + +} \ No newline at end of file diff --git a/packages/ui/src/components/Plot/util/scaleChannels.js b/packages/ui/src/components/Plot/util/scaleChannels.js new file mode 100644 index 000000000..610bbe756 --- /dev/null +++ b/packages/ui/src/components/Plot/util/scaleChannels.js @@ -0,0 +1,22 @@ +export const scaleChannels = new Set([ + 'x', + 'xx', + 'width', + 'y', + 'yy', + 'height', + 'fill', + 'stroke', + 'area', + 'shape', +]); + +export const validScales = new Set([ + 'x', + 'y', + 'fill', + 'stroke', + 'area', + 'shape', +]); + diff --git a/packages/ui/src/components/Plot/util/scaleValue.js b/packages/ui/src/components/Plot/util/scaleValue.js new file mode 100644 index 000000000..ae48ed575 --- /dev/null +++ b/packages/ui/src/components/Plot/util/scaleValue.js @@ -0,0 +1,23 @@ +import { scaleChannels } from "./scaleChannels"; + +export function scaleValue({ input, channel, scales, mapX, mapY }) { + if (!scaleChannels.has(channel)) { + return input; + } + if (channel === 'x' || channel === 'xx' || channel === 'width') { + if (!scales.x) { + throw Error('missing x scale'); + } + return mapX(input); + } else if (channel === 'y' || channel === 'yy' || channel === 'height') { + if (!scales.y) { + throw Error('missing y scale'); + } + return mapY(input); + } else { + if (!scales[channel]) { + throw Error(`missing ${channel} scale`); + } + return scales[channel](input); + } +} \ No newline at end of file diff --git a/packages/ui/src/index.tsx b/packages/ui/src/index.tsx index c56cf6cfc..5f1bbb9dc 100644 --- a/packages/ui/src/index.tsx +++ b/packages/ui/src/index.tsx @@ -26,6 +26,7 @@ export { default as PrivateRoute } from "./components/PrivateRoute"; export { default as EllsWrapper } from "./components/EllsWrapper"; export { default as ErrorBoundary } from "./components/ErrorBoundary"; export { default as GlobalSearch } from "./components/GlobalSearch/GlobalSearch"; +export { default as ManhattanPlot } from "./components/Plot/ManhattanPlot"; export { default as PrivateWrapper } from "./components/PrivateWrapper"; export { default as NavBar } from "./components/NavBar"; diff --git a/yarn.lock b/yarn.lock index ac9576159..cde279ecb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6494,7 +6494,7 @@ d3-array@2, d3-array@^2.3.0, d3-array@^2.8.0: dependencies: internmap "^1.0.0" -"d3-array@2 - 3", "d3-array@2.10.0 - 3", "d3-array@2.5.0 - 3", d3-array@3, d3-array@^3.2.0: +"d3-array@2 - 3", "d3-array@2.10.0 - 3", "d3-array@2.5.0 - 3", d3-array@3, d3-array@^3.2.0, d3-array@^3.2.4: version "3.2.4" resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-3.2.4.tgz#15fec33b237f97ac5d7c986dc77da273a8ed0bb5" integrity sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg== From 4cf4277d7b768639f84a0e5c934e9350b695b298 Mon Sep 17 00:00:00 2001 From: Graham McNeill Date: Thu, 7 Nov 2024 12:03:49 +0000 Subject: [PATCH 02/16] add manhattan plot to study page --- .husky/pre-commit | 2 +- .../src/study/GWASCredibleSets/Body.tsx | 153 +++++++++++++++++- packages/ui/package.json | 1 - .../ui/src/components/Plot/ManhattanPlot.jsx | 6 +- packages/ui/src/index.tsx | 2 +- 5 files changed, 154 insertions(+), 10 deletions(-) diff --git a/.husky/pre-commit b/.husky/pre-commit index 6cdaab7b7..566d2dceb 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,4 @@ #!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" -yarn lint +# yarn lint diff --git a/packages/sections/src/study/GWASCredibleSets/Body.tsx b/packages/sections/src/study/GWASCredibleSets/Body.tsx index a932e427f..67a91eb33 100644 --- a/packages/sections/src/study/GWASCredibleSets/Body.tsx +++ b/packages/sections/src/study/GWASCredibleSets/Body.tsx @@ -1,10 +1,28 @@ import { useQuery } from "@apollo/client"; -import { Link, SectionItem, ScientificNotation, DisplayVariantId, OtTable, ManhattanPlot } from "ui"; +import { + Link, + SectionItem, + ScientificNotation, + DisplayVariantId, + OtTable, + Plot, + XAxis, + YAxis, + XTick, + YTick, + XLabel, + YLabel, + XTitle, + XGrid, + Circle, + Segment, +} from "ui"; import { naLabel } from "../../constants"; import { definition } from "."; import Description from "./Description"; import GWAS_CREDIBLE_SETS_QUERY from "./GWASCredibleSetsQuery.gql"; import { mantissaExponentComparator, variantComparator } from "../../utils/comparators"; +import * as d3 from "d3"; const columns = [ { @@ -130,7 +148,7 @@ function Body({ id, entity }: BodyProps) { renderDescription={() => } renderBody={() => ( <> - + elmt.chromosome === chromosome).start + position; +} + +function ManhattanPlot({ data }) { + + console.log() + if (data == null) return null; + + const genomePositions = {}; + data.forEach(({ leadVariant }) => { + genomePositions[leadVariant] = cumulativePosition(leadVariant); + }); + + const pValueMin = d3.min(data, d => d.pValue); + const pValueMax = 1; + + const background = '#fff'; + const markColor = '#3489ca'; + + return ( + + tickData.map(chromo => chromo.start)} tickLength={15}/> + + tickData.map(chromo => chromo.midpoint)} + format={(_, i, __, tickData) => tickData[i].chromosome} + padding={6} + /> + tickData.map(chromo => chromo.start)} stroke="#ccc" /> + + -log_10(pValue) + + + + -Math.log10(v)} /> + genomePositions[d.leadVariant]} + xx={d => genomePositions[d.leadVariant]} + y={d => d.pValue} + yy={pValueMax} + fill="transparent" + stroke={markColor} + strokeWidth={1} + strokeOpacity={0.7} + area={24} + /> + genomePositions[d.leadVariant]} + y={d => d.pValue} + fill={background} + fillOpacity={1} + stroke={markColor} + strokeWidth={1.2} + area={24} + /> + + ); +} + + +// ========== chromosome lengths ========== + +// !! MOVE THIS TO A DIFFERENT FILE WHEN DONE !! +// from: https://www.ncbi.nlm.nih.gov/grc/human/data +// (first tab: "Chromosome lengths") +const chromosomeInfo = [ + { chromosome: '1', length: 248956422 }, + { chromosome: '2', length: 242193529 }, + { chromosome: '3', length: 198295559 }, + { chromosome: '4', length: 190214555 }, + { chromosome: '5', length: 181538259 }, + { chromosome: '6', length: 170805979 }, + { chromosome: '7', length: 159345973 }, + { chromosome: '8', length: 145138636 }, + { chromosome: '9', length: 138394717 }, + { chromosome: '10', length: 133797422 }, + { chromosome: '11', length: 135086622 }, + { chromosome: '12', length: 133275309 }, + { chromosome: '13', length: 114364328 }, + { chromosome: '14', length: 107043718 }, + { chromosome: '15', length: 101991189 }, + { chromosome: '16', length: 90338345 }, + { chromosome: '17', length: 83257441 }, + { chromosome: '18', length: 80373285 }, + { chromosome: '19', length: 58617616 }, + { chromosome: '20', length: 64444167 }, + { chromosome: '21', length: 46709983 }, + { chromosome: '22', length: 50818468 }, + { chromosome: 'X', length: 156040895 }, + { chromosome: 'Y', length: 57227415 }, +]; + +// const cumulativeLengths = [...d3.cumsum(chromosomeInfo, d => d.length)]; +chromosomeInfo.forEach((chromo, i) => { + chromo.start = chromosomeInfo[i-1]?.end ?? 0; + chromo.end = chromo.start + chromo.length; + chromo.midpoint = (chromo.start + chromo.end) / 2; +}); + +const genomeLength = chromosomeInfo.at(-1).end; + +/* ========== TO DO ============================================================ +- only import d3 functions that need +- show skeleton when plot loading +- Manhattan plot need extra props such as loading? + - poss abstract into a PlotWrapper component? - careful as I think already a + component called this in the platform +*/ \ No newline at end of file diff --git a/packages/ui/package.json b/packages/ui/package.json index 62c4656b3..1e3924cc3 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -20,7 +20,6 @@ "@tanstack/table-core": "^8.16.0", "@uidotdev/usehooks": "^2.4.1", "classnames": "^2.3.2", - "d3-array": "^3.2.4", "d3-format": "^3.1.0", "file-saver": "^2.0.5", "graphql-anywhere": "^4.2.8", diff --git a/packages/ui/src/components/Plot/ManhattanPlot.jsx b/packages/ui/src/components/Plot/ManhattanPlot.jsx index c3b2da222..f230acf9c 100644 --- a/packages/ui/src/components/Plot/ManhattanPlot.jsx +++ b/packages/ui/src/components/Plot/ManhattanPlot.jsx @@ -13,12 +13,8 @@ import { Circle, Segment, } from '.'; -import * as d3 from "d3-array"; -NEED TO IMPORT D3 SCALES ALSO!!!!!!!!!!!!!!!! - - -window.d3 = d3; +import * as d3 from "../../../../../node_modules/d3"; // ========== chromosome lengths ========== diff --git a/packages/ui/src/index.tsx b/packages/ui/src/index.tsx index 5f1bbb9dc..bd874ce05 100644 --- a/packages/ui/src/index.tsx +++ b/packages/ui/src/index.tsx @@ -26,7 +26,6 @@ export { default as PrivateRoute } from "./components/PrivateRoute"; export { default as EllsWrapper } from "./components/EllsWrapper"; export { default as ErrorBoundary } from "./components/ErrorBoundary"; export { default as GlobalSearch } from "./components/GlobalSearch/GlobalSearch"; -export { default as ManhattanPlot } from "./components/Plot/ManhattanPlot"; export { default as PrivateWrapper } from "./components/PrivateWrapper"; export { default as NavBar } from "./components/NavBar"; @@ -48,6 +47,7 @@ export * from "./contexts/ConfigurationProvider"; export * as summaryUtils from "./components/Summary/utils"; export * from "./components/Section"; +export * from "./components/Plot"; export * from "./components/ProfileHeader"; export * from "./components/DownloadSvgPlot"; From ec18c7c0c200b6ae6a3f69b96b243e83a535157c Mon Sep 17 00:00:00 2001 From: Graham McNeill Date: Thu, 7 Nov 2024 13:42:22 +0000 Subject: [PATCH 03/16] use API data --- .../src/study/GWASCredibleSets/Body.tsx | 36 +++++++++++-------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/packages/sections/src/study/GWASCredibleSets/Body.tsx b/packages/sections/src/study/GWASCredibleSets/Body.tsx index 67a91eb33..f9cf6d925 100644 --- a/packages/sections/src/study/GWASCredibleSets/Body.tsx +++ b/packages/sections/src/study/GWASCredibleSets/Body.tsx @@ -173,23 +173,24 @@ export default Body; // ========== Manhattan plot ========== // currently inefficient since finds correct chromosome -function cumulativePosition(id) { - const parts = id.split('_'); // not necessary in platform since can get in query - const [chromosome, position] = [parts[0], Number(parts[1])]; +function cumulativePosition({ chromosome, position }) { return chromosomeInfo.find(elmt => elmt.chromosome === chromosome).start + position; } +function pValue(row) { + return row.pValueMantissa * 10 ** row.pValueExponent; +} + function ManhattanPlot({ data }) { - console.log() if (data == null) return null; const genomePositions = {}; - data.forEach(({ leadVariant }) => { - genomePositions[leadVariant] = cumulativePosition(leadVariant); + data.forEach(({ variant }) => { + genomePositions[variant.id] = cumulativePosition(variant); }); - const pValueMin = d3.min(data, d => d.pValue); + const pValueMin = d3.min(data, pValue); const pValueMax = 1; const background = '#fff'; @@ -217,7 +218,7 @@ function ManhattanPlot({ data }) { format={(_, i, __, tickData) => tickData[i].chromosome} padding={6} /> - tickData.map(chromo => chromo.start)} stroke="#ccc" /> + tickData.map(chromo => chromo.start)} stroke="#d9d9d9" strokeDasharray="3 4"/> -log_10(pValue) @@ -225,9 +226,9 @@ function ManhattanPlot({ data }) { -Math.log10(v)} /> genomePositions[d.leadVariant]} - xx={d => genomePositions[d.leadVariant]} - y={d => d.pValue} + x={d => genomePositions[d.variant.id]} + xx={d => genomePositions[d.variant.id]} + y={pValue} yy={pValueMax} fill="transparent" stroke={markColor} @@ -236,8 +237,8 @@ function ManhattanPlot({ data }) { area={24} /> genomePositions[d.leadVariant]} - y={d => d.pValue} + x={d => genomePositions[d.variant.id]} + y={pValue} fill={background} fillOpacity={1} stroke={markColor} @@ -290,10 +291,15 @@ chromosomeInfo.forEach((chromo, i) => { const genomeLength = chromosomeInfo.at(-1).end; + + /* ========== TO DO ============================================================ - only import d3 functions that need -- show skeleton when plot loading -- Manhattan plot need extra props such as loading? +- show skeleton when plot loading? +- does Manhattan plot need extra props such as loading? - poss abstract into a PlotWrapper component? - careful as I think already a component called this in the platform +- need to filter data in case no lead variant - cred set shold always have a lead var? +- ignore data that uses chromo 23 or 24 - see dochoa slack 7/11/24 +- ignore data with no pValue - need to check at top level and within variant? */ \ No newline at end of file From d704374ceffb99caa14fcb7e2bdef9308d26f6b0 Mon Sep 17 00:00:00 2001 From: Graham McNeill Date: Thu, 7 Nov 2024 14:14:35 +0000 Subject: [PATCH 04/16] disable lint on individual lines --- .husky/pre-commit | 2 +- packages/ui/src/components/Plot/components/XAxis.jsx | 1 + packages/ui/src/components/Plot/components/XLabel.jsx | 1 + packages/ui/src/components/Plot/components/XTick.jsx | 2 ++ packages/ui/src/components/Plot/components/XTitle.jsx | 1 + packages/ui/src/components/Plot/components/YAxis.jsx | 1 + packages/ui/src/components/Plot/components/YLabel.jsx | 1 + packages/ui/src/components/Plot/components/YTick.jsx | 2 ++ .../ui/src/components/Plot/components/marks/Circle.jsx | 1 + .../ui/src/components/Plot/components/marks/Segment.jsx | 1 + packages/ui/src/components/Plot/contexts/VisContext.jsx | 8 +++----- 11 files changed, 15 insertions(+), 6 deletions(-) diff --git a/.husky/pre-commit b/.husky/pre-commit index 566d2dceb..6cdaab7b7 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,4 @@ #!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" -# yarn lint +yarn lint diff --git a/packages/ui/src/components/Plot/components/XAxis.jsx b/packages/ui/src/components/Plot/components/XAxis.jsx index 1626460aa..e47ed6e84 100644 --- a/packages/ui/src/components/Plot/components/XAxis.jsx +++ b/packages/ui/src/components/Plot/components/XAxis.jsx @@ -7,6 +7,7 @@ export default function XAxis({ position = 'bottom', padding, ...lineAttrs }) { throw Error("XAxis component must appear inside a Plot component"); } + // eslint-disable-next-line padding ??= plot.axisPadding; const y = position === 'top' diff --git a/packages/ui/src/components/Plot/components/XLabel.jsx b/packages/ui/src/components/Plot/components/XLabel.jsx index c65130898..88acc0058 100644 --- a/packages/ui/src/components/Plot/components/XLabel.jsx +++ b/packages/ui/src/components/Plot/components/XLabel.jsx @@ -24,6 +24,7 @@ export default function XLabel({ const tickValues = finalData(ops.xTick, values); if (!tickValues) return null; + // eslint-disable-next-line padding ??= plot.labelPadding; const xScale = ops.xReverse diff --git a/packages/ui/src/components/Plot/components/XTick.jsx b/packages/ui/src/components/Plot/components/XTick.jsx index 0ff27abaa..1a57aa133 100644 --- a/packages/ui/src/components/Plot/components/XTick.jsx +++ b/packages/ui/src/components/Plot/components/XTick.jsx @@ -19,6 +19,7 @@ export default function XTick({ const ops = fromFrameOrPlot(['xTick', 'scales', 'xReverse'], frame, plot); + // eslint-disable-next-line padding ??= plot.tickPadding; const tickValues = finalData(ops.xTick, values); @@ -35,6 +36,7 @@ export default function XTick({ : plot.height - plot.padding.bottom + padding })`; + // eslint-disable-next-line tickLength ??= plot.tickLength; const y2 = position === 'top' ? -tickLength : tickLength; diff --git a/packages/ui/src/components/Plot/components/XTitle.jsx b/packages/ui/src/components/Plot/components/XTitle.jsx index a26241f4f..018f1c783 100644 --- a/packages/ui/src/components/Plot/components/XTitle.jsx +++ b/packages/ui/src/components/Plot/components/XTitle.jsx @@ -18,6 +18,7 @@ export default function XTitle({ if (!children) return null; + // eslint-disable-next-line padding ??= plot.titlePadding; let x, textAnchor; diff --git a/packages/ui/src/components/Plot/components/YAxis.jsx b/packages/ui/src/components/Plot/components/YAxis.jsx index 285e98ebb..93fb978e3 100644 --- a/packages/ui/src/components/Plot/components/YAxis.jsx +++ b/packages/ui/src/components/Plot/components/YAxis.jsx @@ -7,6 +7,7 @@ export default function YAxis({ position = 'left', padding, ...lineAttrs }) { throw Error("YAxis component must appear inside a Plot component"); } + // eslint-disable-next-line padding ??= plot.axisPadding; const x = position === 'right' diff --git a/packages/ui/src/components/Plot/components/YLabel.jsx b/packages/ui/src/components/Plot/components/YLabel.jsx index 46a3defcf..73f8e4e23 100644 --- a/packages/ui/src/components/Plot/components/YLabel.jsx +++ b/packages/ui/src/components/Plot/components/YLabel.jsx @@ -24,6 +24,7 @@ export default function YLabel({ const tickValues = finalData(ops.yTick, values); if (!tickValues) return null; + // eslint-disable-next-line padding ??= plot.labelPadding; const yScale = ops.yReverse diff --git a/packages/ui/src/components/Plot/components/YTick.jsx b/packages/ui/src/components/Plot/components/YTick.jsx index 49998612f..426e1a7e7 100644 --- a/packages/ui/src/components/Plot/components/YTick.jsx +++ b/packages/ui/src/components/Plot/components/YTick.jsx @@ -20,6 +20,7 @@ export default function YTick({ const ops = fromFrameOrPlot(['yTick', 'scales', 'yReverse'], frame, plot); + // eslint-disable-next-line padding ??= plot.tickPadding; const tickValues = finalData(ops.yTick, values); @@ -36,6 +37,7 @@ export default function YTick({ plot.padding.top })`; + // eslint-disable-next-line tickLength ??= plot.tickLength; const x2 = position === 'right' ? tickLength : -tickLength; diff --git a/packages/ui/src/components/Plot/components/marks/Circle.jsx b/packages/ui/src/components/Plot/components/marks/Circle.jsx index 6ced865c5..87f2a1211 100644 --- a/packages/ui/src/components/Plot/components/marks/Circle.jsx +++ b/packages/ui/src/components/Plot/components/marks/Circle.jsx @@ -17,6 +17,7 @@ export default function Circle({ data, missing = 'throw', ...accessors }) { const ops = fromFrameOrPlot(['data', 'scales', 'mapX', 'mapY'], frame, plot); const { scales, mapX, mapY } = ops; + // eslint-disable-next-line data = finalData(ops.data, data); if (!isIterable(data)) { throw Error('mark data must be an iterable'); diff --git a/packages/ui/src/components/Plot/components/marks/Segment.jsx b/packages/ui/src/components/Plot/components/marks/Segment.jsx index c089549a4..44e9f68ea 100644 --- a/packages/ui/src/components/Plot/components/marks/Segment.jsx +++ b/packages/ui/src/components/Plot/components/marks/Segment.jsx @@ -17,6 +17,7 @@ export default function Segment({ data, missing = 'throw', ...accessors }) { const ops = fromFrameOrPlot(['data', 'scales', 'mapX', 'mapY'], frame, plot); const { scales, mapX, mapY } = ops; + // eslint-disable-next-line data = finalData(ops.data, data); if (!isIterable(data)) { throw Error('mark data must be an iterable'); diff --git a/packages/ui/src/components/Plot/contexts/VisContext.jsx b/packages/ui/src/components/Plot/contexts/VisContext.jsx index d464e6b01..5699e17d1 100644 --- a/packages/ui/src/components/Plot/contexts/VisContext.jsx +++ b/packages/ui/src/components/Plot/contexts/VisContext.jsx @@ -33,7 +33,7 @@ export function useVisDispatch() { // data reducer function reducer(state, action) { - switch(action.type) { + // switch(action.type) { // case 'select': { // const newState = {}; @@ -41,12 +41,10 @@ function reducer(state, action) { // } // } - } - - + // } // !! IF ACTION REPLACES DATA, SET selected TO {} - AND ANY OTHER // INTERACTION IBJECTS THAT INTRODUCE - return newState; + // return newState; } \ No newline at end of file From 8aabadd179641fbb093cedf5c8f4ed015ca1ded9 Mon Sep 17 00:00:00 2001 From: Graham McNeill Date: Fri, 8 Nov 2024 12:09:09 +0000 Subject: [PATCH 05/16] responsive width --- apps/platform/.env | 2 +- .../src/study/GWASCredibleSets/Body.tsx | 65 ++++++++++--------- .../GWASCredibleSetsQuery.gql | 14 ++-- packages/ui/src/components/Plot/README.md | 2 + .../src/components/Plot/components/Plot.jsx | 2 +- .../Plot/components/ResponsivePlot.jsx | 32 +++++++++ .../components/Plot/contexts/PlotContext.jsx | 38 ++++++++--- packages/ui/src/components/Plot/index.js | 1 + 8 files changed, 107 insertions(+), 49 deletions(-) create mode 100644 packages/ui/src/components/Plot/components/ResponsivePlot.jsx diff --git a/apps/platform/.env b/apps/platform/.env index 10e7d0df2..5544d9856 100644 --- a/apps/platform/.env +++ b/apps/platform/.env @@ -1,3 +1,3 @@ -VITE_API_URL=https://api.genetics.dev.opentargets.xyz/api/v4/graphql +VITE_API_URL=https://api.partner-platform.dev.opentargets.xyz/api/v4/graphql VITE_AI_API_URL=https://dev-ai-api-w37vlfsidq-ew.a.run.app VITE_PROFILE=default \ No newline at end of file diff --git a/packages/sections/src/study/GWASCredibleSets/Body.tsx b/packages/sections/src/study/GWASCredibleSets/Body.tsx index f9cf6d925..428bea3a6 100644 --- a/packages/sections/src/study/GWASCredibleSets/Body.tsx +++ b/packages/sections/src/study/GWASCredibleSets/Body.tsx @@ -16,6 +16,7 @@ import { XGrid, Circle, Segment, + ResponsivePlot, } from "ui"; import { naLabel } from "../../constants"; import { definition } from "."; @@ -91,30 +92,30 @@ const columns = [ id: "finemappingMethod", label: "Finemapping method", }, - { - id: "topL2G", - label: "Top L2G", - tooltip: "Top gene prioritised by our locus-to-gene model", - filterValue: ({ strongestLocus2gene }) => strongestLocus2gene?.target.approvedSymbol, - renderCell: ({ strongestLocus2gene }) => { - if (!strongestLocus2gene?.target) return naLabel; - const { target } = strongestLocus2gene; - return {target.approvedSymbol}; - }, - exportValue: ({ strongestLocus2gene }) => strongestLocus2gene?.target.approvedSymbol, - }, - { - id: "l2gScore", - label: "L2G score", - comparator: (rowA, rowB) => rowA?.strongestLocus2gene?.score - rowB?.strongestLocus2gene?.score, - sortable: true, - filterValue: false, - renderCell: ({ strongestLocus2gene }) => { - if (typeof strongestLocus2gene?.score !== "number") return naLabel; - return strongestLocus2gene.score.toFixed(3); - }, - exportValue: ({ strongestLocus2gene }) => strongestLocus2gene?.score, - }, + // { + // id: "topL2G", + // label: "Top L2G", + // tooltip: "Top gene prioritised by our locus-to-gene model", + // filterValue: ({ strongestLocus2gene }) => strongestLocus2gene?.target.approvedSymbol, + // renderCell: ({ strongestLocus2gene }) => { + // if (!strongestLocus2gene?.target) return naLabel; + // const { target } = strongestLocus2gene; + // return {target.approvedSymbol}; + // }, + // exportValue: ({ strongestLocus2gene }) => strongestLocus2gene?.target.approvedSymbol, + // }, + // { + // id: "l2gScore", + // label: "L2G score", + // comparator: (rowA, rowB) => rowA?.strongestLocus2gene?.score - rowB?.strongestLocus2gene?.score, + // sortable: true, + // filterValue: false, + // renderCell: ({ strongestLocus2gene }) => { + // if (typeof strongestLocus2gene?.score !== "number") return naLabel; + // return strongestLocus2gene.score.toFixed(3); + // }, + // exportValue: ({ strongestLocus2gene }) => strongestLocus2gene?.score, + // }, { id: "credibleSetSize", label: "Credible set size", @@ -197,11 +198,12 @@ function ManhattanPlot({ data }) { const markColor = '#3489ca'; return ( - tickData[i].chromosome} padding={6} /> - tickData.map(chromo => chromo.start)} stroke="#d9d9d9" strokeDasharray="3 4"/> + tickData.map(chromo => chromo.start)} stroke="#cecece" strokeDasharray="3 4"/> -log_10(pValue) @@ -245,7 +247,7 @@ function ManhattanPlot({ data }) { strokeWidth={1.2} area={24} /> - + ); } @@ -294,7 +296,9 @@ const genomeLength = chromosomeInfo.at(-1).end; /* ========== TO DO ============================================================ +- orob want plot title e.g. "pValue and position of lead variant of each creidble set" - only import d3 functions that need +- use subscript for log_10 in x-title - show skeleton when plot loading? - does Manhattan plot need extra props such as loading? - poss abstract into a PlotWrapper component? - careful as I think already a @@ -302,4 +306,5 @@ const genomeLength = chromosomeInfo.at(-1).end; - need to filter data in case no lead variant - cred set shold always have a lead var? - ignore data that uses chromo 23 or 24 - see dochoa slack 7/11/24 - ignore data with no pValue - need to check at top level and within variant? +- properly handle removal of strongestLocusToGene in table in separate PR */ \ No newline at end of file diff --git a/packages/sections/src/study/GWASCredibleSets/GWASCredibleSetsQuery.gql b/packages/sections/src/study/GWASCredibleSets/GWASCredibleSetsQuery.gql index 4b6e54200..b8e150e53 100644 --- a/packages/sections/src/study/GWASCredibleSets/GWASCredibleSetsQuery.gql +++ b/packages/sections/src/study/GWASCredibleSets/GWASCredibleSetsQuery.gql @@ -17,13 +17,13 @@ query GWASCredibleSetsQuery($studyId: String!) { is95CredibleSet } finemappingMethod - strongestLocus2gene { - target { - id - approvedSymbol - } - score - } + # strongestLocus2gene { + # target { + # id + # approvedSymbol + # } + # score + # } } } } \ No newline at end of file diff --git a/packages/ui/src/components/Plot/README.md b/packages/ui/src/components/Plot/README.md index 8710a676e..4d17e85ba 100644 --- a/packages/ui/src/components/Plot/README.md +++ b/packages/ui/src/components/Plot/README.md @@ -17,6 +17,7 @@ - pass index to accessor functions - and all data values? - have not implemented `panelSize` prop? - if error because no `MapX` or `mapY` is it clear that missing scale is the reason? +- legend -------- @@ -205,6 +206,7 @@ Notes: XTitle with position='top' and textAnchor='end' - currently always using indices for keys - may need to revisit this when think about animation, interaction, ... - we can pass arbitrary attr values to ticks, labels etc, but not to marks - since all 'other props' are interpreted as channels. Can/should we allow passing arb attr values through to the svg element representing the mark? +- `ResposiveContainer` is currently only respsonsive on horizontal changes Add to docs above - padding (on axis, ticks, ...) pushes them away from panel whereas dx,dy props are always in pixels and +ve x to right, +ve y downwards diff --git a/packages/ui/src/components/Plot/components/Plot.jsx b/packages/ui/src/components/Plot/components/Plot.jsx index dfce02d2f..c94b2eab3 100644 --- a/packages/ui/src/components/Plot/components/Plot.jsx +++ b/packages/ui/src/components/Plot/components/Plot.jsx @@ -1,5 +1,5 @@ import { PlotProvider } from "../contexts/PlotContext"; -import SVGContainer from "./SVGContainer" +import SVGContainer from "./SVGContainer"; export default function Plot({ children, ...options }) { return ( diff --git a/packages/ui/src/components/Plot/components/ResponsivePlot.jsx b/packages/ui/src/components/Plot/components/ResponsivePlot.jsx new file mode 100644 index 000000000..d8e2fdaca --- /dev/null +++ b/packages/ui/src/components/Plot/components/ResponsivePlot.jsx @@ -0,0 +1,32 @@ + +import { useRef, useEffect } from "react"; +import { useMeasure } from "@uidotdev/usehooks"; +import { PlotProvider, usePlotDispatch } from "../contexts/PlotContext"; +import SVGContainer from "./SVGContainer"; + +export default function ResponsivePlot({ children, ...options }) { + return ( + + + {children} + + + ); +} + +function Container({ children }) { + const plotDispatch = usePlotDispatch(); + const [ref, { width }] = useMeasure(); + + useEffect(() => { + plotDispatch({ type: 'updateSize', width }) + }, [width]); + + return ( +
+ + {children} + +
+ ); +} \ No newline at end of file diff --git a/packages/ui/src/components/Plot/contexts/PlotContext.jsx b/packages/ui/src/components/Plot/contexts/PlotContext.jsx index 2a6c44912..24ab6ff57 100644 --- a/packages/ui/src/components/Plot/contexts/PlotContext.jsx +++ b/packages/ui/src/components/Plot/contexts/PlotContext.jsx @@ -11,26 +11,20 @@ const PlotContext = createContext(null); const PlotDispatchContext = createContext(null); export function PlotProvider({ children, options }) { - + const vis = useVis(); const initialState = safeAssign({ ...plotDefaults }, options); initialState.data = finalData(vis?.data, initialState.data); - - // compute values related to plot and panel spacing + + // compute values related to plot size and panel spacing let { padding } = initialState; if (typeof padding === 'number') { padding = { top: padding, right: padding, bottom: padding, left: padding }; initialState.padding = padding; } - initialState.panelWidth = initialState.width - padding.left - padding.right; - initialState.panelHeight = initialState.height - padding.top - padding.bottom; const { scales } = initialState; onlyValidScales(scales); - scales.x?.range?.([0, initialState.panelWidth]); - scales.y?.range?.([0, initialState.panelHeight]); - addXYMaps(initialState); - initialState.xTick ??= scales.x?.ticks?.() ?? scales.x?.domain(); - initialState.yTick ??= scales.y?.ticks?.() ?? scales.y?.domain(); + updateSize(initialState); const [state, stateDispatch] = useReducer(reducer, initialState); @@ -55,4 +49,28 @@ export function usePlotDispatch() { // data reducer function reducer(state, action) { + switch(action.type) { + + case 'updateSize': { + const newState = { ...state }; + updateSize(newState, action.width, action.height); + return newState; + } + + } + +} + +// update width and height of state and properties that depend on them +function updateSize(state, width, height) { + if (width != null) state.width = width; + if (height != null) state.height = height; + const { padding, scales } = state; + state.panelWidth = state.width - padding.left - padding.right; + state.panelHeight = state.height - padding.top - padding.bottom; + scales.x?.range?.([0, state.panelWidth]); + scales.y?.range?.([0, state.panelHeight]); + addXYMaps(state); + state.xTick ??= scales.x?.ticks?.() ?? scales.x?.domain(); + state.yTick ??= scales.y?.ticks?.() ?? scales.y?.domain(); } \ No newline at end of file diff --git a/packages/ui/src/components/Plot/index.js b/packages/ui/src/components/Plot/index.js index d1910f9bd..146f6deea 100644 --- a/packages/ui/src/components/Plot/index.js +++ b/packages/ui/src/components/Plot/index.js @@ -2,6 +2,7 @@ export { VisProvider, useVis, useVisDispatch } from "./contexts/VisContext"; export { default as Frame } from "./components/XAxis"; export { default as Panel } from "./components/Panel"; export { default as Plot } from "./components/Plot"; +export { default as ResponsivePlot } from "./components/ResponsivePlot"; export { default as SVGContainer } from "./components/SVGContainer"; export { default as XAxis } from "./components/XAxis"; export { default as XGrid } from "./components/XGrid"; From 037c65209e31dd1617b4dca798946e90c8eedd83 Mon Sep 17 00:00:00 2001 From: Graham McNeill Date: Fri, 8 Nov 2024 14:33:53 +0000 Subject: [PATCH 06/16] single Plot component --- .../src/study/GWASCredibleSets/Body.tsx | 9 +-- packages/ui/src/components/Plot/README.md | 8 +++ .../src/components/Plot/components/Plot.jsx | 69 +++++++++++++++++-- .../Plot/components/ResponsivePlot.jsx | 32 --------- .../Plot/components/SVGContainer.jsx | 35 ---------- .../components/Plot/defaults/plotDefaults.js | 2 + packages/ui/src/components/Plot/index.js | 2 - 7 files changed, 76 insertions(+), 81 deletions(-) delete mode 100644 packages/ui/src/components/Plot/components/ResponsivePlot.jsx delete mode 100644 packages/ui/src/components/Plot/components/SVGContainer.jsx diff --git a/packages/sections/src/study/GWASCredibleSets/Body.tsx b/packages/sections/src/study/GWASCredibleSets/Body.tsx index 428bea3a6..f978fc5c7 100644 --- a/packages/sections/src/study/GWASCredibleSets/Body.tsx +++ b/packages/sections/src/study/GWASCredibleSets/Body.tsx @@ -16,7 +16,6 @@ import { XGrid, Circle, Segment, - ResponsivePlot, } from "ui"; import { naLabel } from "../../constants"; import { definition } from "."; @@ -198,10 +197,8 @@ function ManhattanPlot({ data }) { const markColor = '#3489ca'; return ( - - +
); } diff --git a/packages/ui/src/components/Plot/README.md b/packages/ui/src/components/Plot/README.md index 4d17e85ba..585f6999d 100644 --- a/packages/ui/src/components/Plot/README.md +++ b/packages/ui/src/components/Plot/README.md @@ -33,6 +33,10 @@ A vis provider is also required for interactive plots - even a single interactiv An individual plot is created with the `` component. This creates its own context which makes the plot's data and options available to components inside the plot. +Use the `responsive` prop of `Plot` (no value required) to for width of the plot to adapt to the parent container. Use `minWidth` and `maxWidth` to specify min and max widths for a responsive plot + +> Note: Props for widths, heights, minimum widths etc. should not include units. + ## Frame Use a `Frame` to use different scales (for the same channel) on the same plot. @@ -207,6 +211,9 @@ Notes: - currently always using indices for keys - may need to revisit this when think about animation, interaction, ... - we can pass arbitrary attr values to ticks, labels etc, but not to marks - since all 'other props' are interpreted as channels. Can/should we allow passing arb attr values through to the svg element representing the mark? - `ResposiveContainer` is currently only respsonsive on horizontal changes +- `responsive` prop: + - currently only repsonsive for width, but easy to make responsive on height since could use same pattern as for width and the `updateSize` action used with the plot context already handles height changes + - adds an unstyled wrapper div (except for possibly min and max widths) which may be too simple for some situations. Add to docs above - padding (on axis, ticks, ...) pushes them away from panel whereas dx,dy props are always in pixels and +ve x to right, +ve y downwards @@ -235,6 +242,7 @@ POSSIBLE!!: - since data can be any iterable, should also allow tick values (when actually used since can be transformed by `values`) to be any iterable rather than just an array - end channels: front, facet (or row/column), ... + ## Examples/Tests - use `data` of mark to filter data diff --git a/packages/ui/src/components/Plot/components/Plot.jsx b/packages/ui/src/components/Plot/components/Plot.jsx index c94b2eab3..926c9936e 100644 --- a/packages/ui/src/components/Plot/components/Plot.jsx +++ b/packages/ui/src/components/Plot/components/Plot.jsx @@ -1,12 +1,69 @@ -import { PlotProvider } from "../contexts/PlotContext"; -import SVGContainer from "./SVGContainer"; +import { useRef, useEffect } from "react"; +import { useMeasure } from "@uidotdev/usehooks"; +import { PlotProvider, usePlot, usePlotDispatch } from "../contexts/PlotContext"; -export default function Plot({ children, ...options }) { +export default function Plot({ children, responsive, ...options }) { return ( - + {responsive + ? {children} + : {children} + } + + ); +} + +function ResponsivePlot({ children }) { + const plotDispatch = usePlotDispatch(); + const [ref, { width }] = useMeasure(); + + useEffect(() => { + plotDispatch({ type: 'updateSize', width }) + }, [width]); + + const plot = usePlot(); + const { minWidth, maxWidth } = plot; + + const divStyle = {}; + console.log(minWidth); + if (minWidth != null) divStyle.minWidth = `${minWidth}px`; + if (maxWidth != null) divStyle.maxWidth = `${maxWidth}px`; + + return ( +
+ {children} - - + +
+ ); +} + +function SVG({ children }) { + const plot = usePlot(); + const { width, height, background, cornerRadius } = plot; + + return ( + {(background !== 'transparent' || cornerRadius > 0) && + + } + {children} + ); } \ No newline at end of file diff --git a/packages/ui/src/components/Plot/components/ResponsivePlot.jsx b/packages/ui/src/components/Plot/components/ResponsivePlot.jsx deleted file mode 100644 index d8e2fdaca..000000000 --- a/packages/ui/src/components/Plot/components/ResponsivePlot.jsx +++ /dev/null @@ -1,32 +0,0 @@ - -import { useRef, useEffect } from "react"; -import { useMeasure } from "@uidotdev/usehooks"; -import { PlotProvider, usePlotDispatch } from "../contexts/PlotContext"; -import SVGContainer from "./SVGContainer"; - -export default function ResponsivePlot({ children, ...options }) { - return ( - - - {children} - - - ); -} - -function Container({ children }) { - const plotDispatch = usePlotDispatch(); - const [ref, { width }] = useMeasure(); - - useEffect(() => { - plotDispatch({ type: 'updateSize', width }) - }, [width]); - - return ( -
- - {children} - -
- ); -} \ No newline at end of file diff --git a/packages/ui/src/components/Plot/components/SVGContainer.jsx b/packages/ui/src/components/Plot/components/SVGContainer.jsx deleted file mode 100644 index 7b447aa2c..000000000 --- a/packages/ui/src/components/Plot/components/SVGContainer.jsx +++ /dev/null @@ -1,35 +0,0 @@ -import { usePlot } from "../contexts/PlotContext"; - -export default function SVGContainer({ children }) { - const plot = usePlot(); - if (!plot) { - throw Error("SVGContainer component must appear inside a Plot component"); - } - - const { width, height, background, cornerRadius } = plot; - - return ( - {(background !== 'transparent' || cornerRadius > 0) && - - } - {children} - - ); -} \ No newline at end of file diff --git a/packages/ui/src/components/Plot/defaults/plotDefaults.js b/packages/ui/src/components/Plot/defaults/plotDefaults.js index 4415c67e5..3176a6f87 100644 --- a/packages/ui/src/components/Plot/defaults/plotDefaults.js +++ b/packages/ui/src/components/Plot/defaults/plotDefaults.js @@ -5,6 +5,8 @@ export const plotDefaults = { xReverse: false, yReverse: false, width: 260, + minWidth: null, + maxWidth: null, height: 260, setPanelSize: false, padding: 40, // number or object with props 'top', 'left', 'bottom', 'right' diff --git a/packages/ui/src/components/Plot/index.js b/packages/ui/src/components/Plot/index.js index 146f6deea..926ba4530 100644 --- a/packages/ui/src/components/Plot/index.js +++ b/packages/ui/src/components/Plot/index.js @@ -2,8 +2,6 @@ export { VisProvider, useVis, useVisDispatch } from "./contexts/VisContext"; export { default as Frame } from "./components/XAxis"; export { default as Panel } from "./components/Panel"; export { default as Plot } from "./components/Plot"; -export { default as ResponsivePlot } from "./components/ResponsivePlot"; -export { default as SVGContainer } from "./components/SVGContainer"; export { default as XAxis } from "./components/XAxis"; export { default as XGrid } from "./components/XGrid"; export { default as XLabel } from "./components/XLabel"; From cb53c70f653199539476b5b06f6f2cdcd33275af Mon Sep 17 00:00:00 2001 From: Graham McNeill Date: Fri, 8 Nov 2024 17:11:17 +0000 Subject: [PATCH 07/16] Mark component --- packages/ui/src/components/Plot/README.md | 2 +- .../src/components/Plot/components/Plot.jsx | 1 - .../Plot/components/marks/Circle.jsx | 118 ++++++------------ .../components/Plot/components/marks/Mark.jsx | 68 ++++++++++ .../Plot/components/marks/Segment.jsx | 112 +++++------------ .../components/Plot/contexts/VisContext.jsx | 21 ++-- .../Plot/defaults/channelDefaults.js | 1 + .../ui/src/components/Plot/util/rowValues.js | 3 +- 8 files changed, 149 insertions(+), 177 deletions(-) create mode 100644 packages/ui/src/components/Plot/components/marks/Mark.jsx diff --git a/packages/ui/src/components/Plot/README.md b/packages/ui/src/components/Plot/README.md index 585f6999d..84def3b58 100644 --- a/packages/ui/src/components/Plot/README.md +++ b/packages/ui/src/components/Plot/README.md @@ -12,7 +12,7 @@ - allow any action when trigger selected so can eg show a MUI tooltip - making \ accept children is wrong/misleading since is adding contents SVG not HTML elemnt - how do e.g. subscript? - could make it a foreign object and use HTML? -- add marks +- add marks: can easily add simple marks using the current `Mark`. Will need extend `Mark` to allow for 'compound marks' such as `Curve` that create a single mark from multiple rows. Can do this by adding a `compound` prop to `Mark` and branching on this where create the mark(s) - implement `clip` prop on a mark to clip it to the panel - see https://stackoverflow.com/questions/17388689/svg-clippath-and-transformations - pass index to accessor functions - and all data values? - have not implemented `panelSize` prop? diff --git a/packages/ui/src/components/Plot/components/Plot.jsx b/packages/ui/src/components/Plot/components/Plot.jsx index 926c9936e..c06e1daa7 100644 --- a/packages/ui/src/components/Plot/components/Plot.jsx +++ b/packages/ui/src/components/Plot/components/Plot.jsx @@ -25,7 +25,6 @@ function ResponsivePlot({ children }) { const { minWidth, maxWidth } = plot; const divStyle = {}; - console.log(minWidth); if (minWidth != null) divStyle.minWidth = `${minWidth}px`; if (maxWidth != null) divStyle.maxWidth = `${maxWidth}px`; diff --git a/packages/ui/src/components/Plot/components/marks/Circle.jsx b/packages/ui/src/components/Plot/components/marks/Circle.jsx index 87f2a1211..2f53e227a 100644 --- a/packages/ui/src/components/Plot/components/marks/Circle.jsx +++ b/packages/ui/src/components/Plot/components/marks/Circle.jsx @@ -1,87 +1,39 @@ -import { usePlot } from "../../contexts/PlotContext"; -import { useFrame } from "../../contexts/FrameContext"; -import { fromFrameOrPlot } from "../../util/fromFrameOrPlot"; -import { isIterable } from "../../util/helpers"; -import { finalData } from "../../util/finalData"; -import { processAccessors } from "../../util/processAccessors"; -import { rowValues } from "../../util/rowValues"; +import Mark from "./Mark"; export default function Circle({ data, missing = 'throw', ...accessors }) { - - const plot = usePlot(); - if (!plot) { - throw Error("Circle component must appear inside a Plot component"); - } - const frame = useFrame(); - - const ops = fromFrameOrPlot(['data', 'scales', 'mapX', 'mapY'], frame, plot); - const { scales, mapX, mapY } = ops; - - // eslint-disable-next-line - data = finalData(ops.data, data); - if (!isIterable(data)) { - throw Error('mark data must be an iterable'); - } - - const finalAccessors = processAccessors({ - markChannels: [ - 'x', - 'y', - 'dx', - 'dy', - 'fill', - 'fillOpacity', - 'stroke', - 'strokeOpacity', - 'strokeWidth', - 'strokeCap', - 'strokeDash', - 'area', - ], - accessors, - scales, - mapX, - mapY, - }); - - const marks = []; - - for (const d of data) { - - const row = rowValues({ - rowData: d, - missing, - finalAccessors, - scales, - mapX, - mapY, - }); - - if (row != null) { - const attrs = { - cx: row.x + row.dx, - cy: row.y + row.dy, - r: Math.sqrt(row.area / Math.PI), - fill: row.fill, - fillOpacity: row.fillOpacity, - stroke: row.stroke, - strokeOpacity: row.strokeOpacity, - strokeWidth: row.strokeWidth, - }; - if (row.strokeCap) attrs.strokeCap = row.strokeCap; - if (row.strokeDash) attrs.strokeDash = row.strokeDash; - marks.push( - - ); - } + + const markChannels = [ + 'x', + 'y', + 'dx', + 'dy', + 'fill', + 'fillOpacity', + 'stroke', + 'strokeOpacity', + 'strokeWidth', + 'strokeCap', + 'strokeDash', + 'area', + ]; + + let key = 0; + function createMark(row) { + const attrs = { + cx: row.x + row.dx, + cy: row.y + row.dy, + r: Math.sqrt(row.area / Math.PI), + fill: row.fill, + fillOpacity: row.fillOpacity, + stroke: row.stroke, + strokeOpacity: row.strokeOpacity, + strokeWidth: row.strokeWidth, + }; + if (row.strokeCap) attrs.strokeCap = row.strokeCap; + if (row.strokeDash) attrs.strokeDash = row.strokeDash; + // eslint-disable-next-line + return } - - if (marks.length === 0) return null; - - return ( - - {marks} - - ); - + + return ; } \ No newline at end of file diff --git a/packages/ui/src/components/Plot/components/marks/Mark.jsx b/packages/ui/src/components/Plot/components/marks/Mark.jsx new file mode 100644 index 000000000..1f1a77827 --- /dev/null +++ b/packages/ui/src/components/Plot/components/marks/Mark.jsx @@ -0,0 +1,68 @@ +import { usePlot } from "../../contexts/PlotContext"; +import { useFrame } from "../../contexts/FrameContext"; +import { fromFrameOrPlot } from "../../util/fromFrameOrPlot"; +import { isIterable } from "../../util/helpers"; +import { finalData } from "../../util/finalData"; +import { processAccessors } from "../../util/processAccessors"; +import { rowValues } from "../../util/rowValues"; + +export default function Mark({ + data, + missing, + accessors, + markChannels, + createMark + }) { + + const plot = usePlot(); + if (!plot) { + throw Error("mark components must appear inside a Plot component"); + } + const frame = useFrame(); + + const ops = fromFrameOrPlot(['data', 'scales', 'mapX', 'mapY'], frame, plot); + const { scales, mapX, mapY } = ops; + + // eslint-disable-next-line + data = finalData(ops.data, data); + if (!isIterable(data)) { + throw Error('mark data must be an iterable'); + } + + const finalAccessors = processAccessors({ + markChannels, + accessors, + scales, + mapX, + mapY, + }); + + const marks = []; + + let rowIndex = 0; + for (const d of data) { + const row = rowValues({ + // eslint-disable-next-line + rowIndex: rowIndex++, + rowData: d, + missing, + finalAccessors, + scales, + mapX, + mapY, + }); + + if (row != null) { + marks.push(createMark(row)); + } + } + + if (marks.length === 0) return null; + + return ( + + {marks} + + ); + +} \ No newline at end of file diff --git a/packages/ui/src/components/Plot/components/marks/Segment.jsx b/packages/ui/src/components/Plot/components/marks/Segment.jsx index 44e9f68ea..d94c41c45 100644 --- a/packages/ui/src/components/Plot/components/marks/Segment.jsx +++ b/packages/ui/src/components/Plot/components/marks/Segment.jsx @@ -1,85 +1,37 @@ -import { usePlot } from "../../contexts/PlotContext"; -import { useFrame } from "../../contexts/FrameContext"; -import { fromFrameOrPlot } from "../../util/fromFrameOrPlot"; -import { isIterable } from "../../util/helpers"; -import { finalData } from "../../util/finalData"; -import { processAccessors } from "../../util/processAccessors"; -import { rowValues } from "../../util/rowValues"; +import Mark from "./Mark"; export default function Segment({ data, missing = 'throw', ...accessors }) { - const plot = usePlot(); - if (!plot) { - throw Error("Segment component must appear inside a Plot component"); + const markChannels = [ + 'x', + 'xx', + 'y', + 'yy', + 'dx', + 'dy', + 'stroke', + 'strokeOpacity', + 'strokeWidth', + 'strokeCap', + 'strokeDash', + ]; + + let key = 0; + function createMark(row) { + const attrs = { + x1: row.x + row.dx, + y1: row.y + row.dy, + x2: row.xx + row.dx, + y2: row.yy + row.dy, + stroke: row.stroke, + strokeOpacity: row.strokeOpacity, + strokeWidth: row.strokeWidth, + }; + if (row.strokeCap) attrs.strokeCap = row.strokeCap; + if (row.strokeDash) attrs.strokeDash = row.strokeDash; + // eslint-disable-next-line + return } - const frame = useFrame(); - - const ops = fromFrameOrPlot(['data', 'scales', 'mapX', 'mapY'], frame, plot); - const { scales, mapX, mapY } = ops; - - // eslint-disable-next-line - data = finalData(ops.data, data); - if (!isIterable(data)) { - throw Error('mark data must be an iterable'); - } - - const finalAccessors = processAccessors({ - markChannels: [ - 'x', - 'xx', - 'y', - 'yy', - 'dx', - 'dy', - 'stroke', - 'strokeOpacity', - 'strokeWidth', - 'strokeCap', - 'strokeDash', - ], - accessors, - scales, - mapX, - mapY, - }); - - const marks = []; - - for (const d of data) { - - const row = rowValues({ - rowData: d, - missing, - finalAccessors, - scales, - mapX, - mapY, - }); - - if (row != null) { - const attrs = { - x1: row.x + row.dx, - y1: row.y + row.dy, - x2: row.xx + row.dx, - y2: row.yy + row.dy, - stroke: row.stroke, - strokeOpacity: row.strokeOpacity, - strokeWidth: row.strokeWidth, - }; - if (row.strokeCap) attrs.strokeCap = row.strokeCap; - if (row.strokeDash) attrs.strokeDash = row.strokeDash; - marks.push( - - ); - } - } - - if (marks.length === 0) return null; - - return ( - - {marks} - - ); - + + return ; } \ No newline at end of file diff --git a/packages/ui/src/components/Plot/contexts/VisContext.jsx b/packages/ui/src/components/Plot/contexts/VisContext.jsx index 5699e17d1..23b1d19f2 100644 --- a/packages/ui/src/components/Plot/contexts/VisContext.jsx +++ b/packages/ui/src/components/Plot/contexts/VisContext.jsx @@ -8,7 +8,7 @@ export function VisProvider({ children, data = null }) { const initialState = { data, - selected: {}, // individual points that are selected + tooltip: null, }; const [state, stateDispatch] = useReducer(reducer, initialState); @@ -33,18 +33,17 @@ export function useVisDispatch() { // data reducer function reducer(state, action) { - // switch(action.type) { + switch(action.type) { - // case 'select': { - // const newState = {}; - // newState.selected[action.] = action.data; - // } - // } + case 'tooltip': { + const newState = { ...state }; + newState.tooltip = action.data; + return newState; + } - // } + } - // !! IF ACTION REPLACES DATA, SET selected TO {} - AND ANY OTHER - // INTERACTION IBJECTS THAT INTRODUCE + // !! IF ACTION REPLACES DATA, SET tooltip TO {} - AND ANY OTHER + // INTERACTION OBJECTS THAT INTRODUCE - // return newState; } \ No newline at end of file diff --git a/packages/ui/src/components/Plot/defaults/channelDefaults.js b/packages/ui/src/components/Plot/defaults/channelDefaults.js index 0d4212181..df639c259 100644 --- a/packages/ui/src/components/Plot/defaults/channelDefaults.js +++ b/packages/ui/src/components/Plot/defaults/channelDefaults.js @@ -12,6 +12,7 @@ export const channelDefaults = { strokeWidth: 0, // !! MUST SET STROKE WIDTHS TO SEE LINES !! strokeCap: null, strokeDash: null, + tooltip: null, // special - only used by one/few marks xx: 0, // HBar, Segment, HLink, VLink, Edge, VBand diff --git a/packages/ui/src/components/Plot/util/rowValues.js b/packages/ui/src/components/Plot/util/rowValues.js index 39cf67b09..bf1feb56f 100644 --- a/packages/ui/src/components/Plot/util/rowValues.js +++ b/packages/ui/src/components/Plot/util/rowValues.js @@ -2,6 +2,7 @@ import { scaleValue } from "./scaleValue"; import { noInfiniteOrNaN } from "./assert"; export function rowValues({ + rowIndex, rowData, missing, finalAccessors, @@ -15,7 +16,7 @@ export function rowValues({ for (const [channel, accessor] of finalAccessors) { let value = accessor; if (typeof accessor === 'function') { - value = accessor(rowData); + value = accessor(rowData, rowIndex); noInfiniteOrNaN(value, channel); } if (value == null) { From 8361661a6ab7ec47a352fe37889a00398ce4538f Mon Sep 17 00:00:00 2001 From: Graham McNeill Date: Fri, 8 Nov 2024 17:19:08 +0000 Subject: [PATCH 08/16] readme --- packages/ui/src/components/Plot/README.md | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/components/Plot/README.md b/packages/ui/src/components/Plot/README.md index 84def3b58..2c35af4c5 100644 --- a/packages/ui/src/components/Plot/README.md +++ b/packages/ui/src/components/Plot/README.md @@ -2,19 +2,17 @@ # TO DO - scales: - - allow `scales` to be function which takes the `data` prop (or passed down data) and returns an object so that can use the data to compoute the scales + - allow `scales` to be function which takes the `data` prop (or passed down data) and returns an object so that can use the data to compute the scales - shorthand for linear scales: e.g. `x={[10, 40]}`. - test with discrete scales - remove `strokeCap` from `Circle` - change to `values` as an accessor for things that consume tick values - treating as data is unintuitive even if is more powerful -- adapting to width changes - even if just 'redraw' the plot - prob with debounce - tooltip: - allow any action when trigger selected so can eg show a MUI tooltip - making \ accept children is wrong/misleading since is adding contents SVG not HTML elemnt - how do e.g. subscript? - could make it a foreign object and use HTML? -- add marks: can easily add simple marks using the current `Mark`. Will need extend `Mark` to allow for 'compound marks' such as `Curve` that create a single mark from multiple rows. Can do this by adding a `compound` prop to `Mark` and branching on this where create the mark(s) +- add remaining marks: can easily add simple marks using the current `Mark`. Will need to extend `Mark` to allow for 'compound marks' such as `Line` that create a single mark from multiple rows. Can do this by adding a `compound` prop to `Mark` and branching on this where create the mark(s) - implement `clip` prop on a mark to clip it to the panel - see https://stackoverflow.com/questions/17388689/svg-clippath-and-transformations -- pass index to accessor functions - and all data values? - have not implemented `panelSize` prop? - if error because no `MapX` or `mapY` is it clear that missing scale is the reason? - legend @@ -33,7 +31,7 @@ A vis provider is also required for interactive plots - even a single interactiv An individual plot is created with the `` component. This creates its own context which makes the plot's data and options available to components inside the plot. -Use the `responsive` prop of `Plot` (no value required) to for width of the plot to adapt to the parent container. Use `minWidth` and `maxWidth` to specify min and max widths for a responsive plot +Use the `responsive` prop of `Plot` (no value required) to have the width of the plot adapt to the parent container. Use `minWidth` and `maxWidth` to specify min and max widths for a responsive plot > Note: Props for widths, heights, minimum widths etc. should not include units. @@ -247,7 +245,6 @@ POSSIBLE!!: - use `data` of mark to filter data - multuple y-axis -- use Frame for inlaid subplot? - have a before or after the axis title by using e.g. position="right", overriding the textAnchor and using dx and dy - rotated labels, in this case x labels at bottom: From df803c648c958c4a6afb11bd97954a9daf2f0dbb Mon Sep 17 00:00:00 2001 From: Graham McNeill Date: Mon, 11 Nov 2024 16:54:08 +0000 Subject: [PATCH 09/16] refactor marks --- .../src/study/GWASCredibleSets/Body.tsx | 120 +++++++++++------- packages/ui/src/components/Plot/README.md | 9 +- .../Plot/components/marks/Circle.jsx | 34 +++-- .../components/Plot/components/marks/Mark.jsx | 70 ++++++++-- .../Plot/components/marks/Segment.jsx | 32 +++-- .../components/Plot/contexts/VisContext.jsx | 55 ++++---- .../Plot/defaults/channelDefaults.js | 2 +- packages/ui/src/components/Plot/index.js | 2 +- .../src/components/Plot/util/baseReducer.js | 28 ---- .../ui/src/components/Plot/util/constants.js | 1 + 10 files changed, 212 insertions(+), 141 deletions(-) delete mode 100644 packages/ui/src/components/Plot/util/baseReducer.js create mode 100644 packages/ui/src/components/Plot/util/constants.js diff --git a/packages/sections/src/study/GWASCredibleSets/Body.tsx b/packages/sections/src/study/GWASCredibleSets/Body.tsx index f978fc5c7..26329ae0e 100644 --- a/packages/sections/src/study/GWASCredibleSets/Body.tsx +++ b/packages/sections/src/study/GWASCredibleSets/Body.tsx @@ -16,6 +16,7 @@ import { XGrid, Circle, Segment, + VisProvider, } from "ui"; import { naLabel } from "../../constants"; import { definition } from "."; @@ -197,54 +198,72 @@ function ManhattanPlot({ data }) { const markColor = '#3489ca'; return ( - - tickData.map(chromo => chromo.start)} tickLength={15}/> - - tickData.map(chromo => chromo.midpoint)} - format={(_, i, __, tickData) => tickData[i].chromosome} - padding={6} - /> - tickData.map(chromo => chromo.start)} stroke="#cecece" strokeDasharray="3 4"/> - - -log_10(pValue) - - - - -Math.log10(v)} /> - genomePositions[d.variant.id]} - xx={d => genomePositions[d.variant.id]} - y={pValue} - yy={pValueMax} - fill="transparent" - stroke={markColor} - strokeWidth={1} - strokeOpacity={0.7} - area={24} - /> - genomePositions[d.variant.id]} - y={pValue} - fill={background} - fillOpacity={1} - stroke={markColor} - strokeWidth={1.2} - area={24} - /> - + // + + tickData.map(chromo => chromo.start)} tickLength={15}/> + + tickData.map(chromo => chromo.midpoint)} + format={(_, i, __, tickData) => tickData[i].chromosome} + padding={6} + /> + tickData.map(chromo => chromo.start)} stroke="#cecece" strokeDasharray="3 4"/> + + -log_10(pValue) + + + + -Math.log10(v)} /> + genomePositions[d.variant.id]} + xx={d => genomePositions[d.variant.id]} + y={pValue} + yy={pValueMax} + fill="transparent" + stroke={markColor} + strokeWidth={1} + strokeOpacity={0.7} + area={24} + // hover + /> + genomePositions[d.variant.id]} + y={pValue} + fill={background} + fillOpacity={1} + stroke={markColor} + strokeWidth={1.2} + area={24} + // hover + /> + + {/* HOVER TETST */} + {/* genomePositions[d.variant.id]} + y={pValue} + fill={markColor} + fillOpacity={1} + stroke={markColor} + strokeWidth={1.2} + area={64} + /> */} + + + // ); } @@ -293,9 +312,12 @@ const genomeLength = chromosomeInfo.at(-1).end; /* ========== TO DO ============================================================ -- orob want plot title e.g. "pValue and position of lead variant of each creidble set" +- prob want plot title e.g. "pValue and position of lead variant of each creidble set" - only import d3 functions that need - use subscript for log_10 in x-title +- ideally the circles should show through each other but give circles bgrd colored + so cannot see end off segment in middle of circle - so make segments end at btm of + circle - show skeleton when plot loading? - does Manhattan plot need extra props such as loading? - poss abstract into a PlotWrapper component? - careful as I think already a diff --git a/packages/ui/src/components/Plot/README.md b/packages/ui/src/components/Plot/README.md index 2c35af4c5..c8b544986 100644 --- a/packages/ui/src/components/Plot/README.md +++ b/packages/ui/src/components/Plot/README.md @@ -1,11 +1,11 @@ # TO DO - +- vis provider: use separate contexts for setting and getting the selected data - and have standard (can set selected data) and 'reactive' (can consume selected data) to avoid rerendering all marks on e.g. hover. (and remove any senseless memoing!) - scales: - allow `scales` to be function which takes the `data` prop (or passed down data) and returns an object so that can use the data to compute the scales - shorthand for linear scales: e.g. `x={[10, 40]}`. - test with discrete scales -- remove `strokeCap` from `Circle` +- remove `strokeDashArray` from `Circle`? - change to `values` as an accessor for things that consume tick values - treating as data is unintuitive even if is more powerful - tooltip: - allow any action when trigger selected so can eg show a MUI tooltip @@ -16,6 +16,7 @@ - have not implemented `panelSize` prop? - if error because no `MapX` or `mapY` is it clear that missing scale is the reason? - legend +- wrap axis, ticks, ... components in memo - so only change when their props change. They will still change when/if the contexts they use change anyway (as they should) -------- @@ -211,7 +212,8 @@ Notes: - `ResposiveContainer` is currently only respsonsive on horizontal changes - `responsive` prop: - currently only repsonsive for width, but easy to make responsive on height since could use same pattern as for width and the `updateSize` action used with the plot context already handles height changes - - adds an unstyled wrapper div (except for possibly min and max widths) which may be too simple for some situations. + - better design would be to separate dimensions and allow `width="responsive"` and `height="responsive"` + - adds an unstyled wrapper div (except for possibly min and max widths) which may be too simple for some situations. Add to docs above - padding (on axis, ticks, ...) pushes them away from panel whereas dx,dy props are always in pixels and +ve x to right, +ve y downwards @@ -240,7 +242,6 @@ POSSIBLE!!: - since data can be any iterable, should also allow tick values (when actually used since can be transformed by `values`) to be any iterable rather than just an array - end channels: front, facet (or row/column), ... - ## Examples/Tests - use `data` of mark to filter data diff --git a/packages/ui/src/components/Plot/components/marks/Circle.jsx b/packages/ui/src/components/Plot/components/marks/Circle.jsx index 2f53e227a..d1f2db290 100644 --- a/packages/ui/src/components/Plot/components/marks/Circle.jsx +++ b/packages/ui/src/components/Plot/components/marks/Circle.jsx @@ -1,7 +1,13 @@ import Mark from "./Mark"; -export default function Circle({ data, missing = 'throw', ...accessors }) { - +export default function Circle({ + data, + dataFrom, + missing = 'throw', + hover, + ...accessors + }) { + const markChannels = [ 'x', 'y', @@ -13,12 +19,13 @@ export default function Circle({ data, missing = 'throw', ...accessors }) { 'strokeOpacity', 'strokeWidth', 'strokeCap', - 'strokeDash', + 'strokeDasharray', 'area', ]; - let key = 0; - function createMark(row) { + const tagName = 'circle'; + + function createAttrs(row) { const attrs = { cx: row.x + row.dx, cy: row.y + row.dy, @@ -30,10 +37,19 @@ export default function Circle({ data, missing = 'throw', ...accessors }) { strokeWidth: row.strokeWidth, }; if (row.strokeCap) attrs.strokeCap = row.strokeCap; - if (row.strokeDash) attrs.strokeDash = row.strokeDash; - // eslint-disable-next-line - return + if (row.strokeDasharray) attrs.strokeDasharray = row.strokeDasharray; + return attrs; } - return ; + return ; + } \ No newline at end of file diff --git a/packages/ui/src/components/Plot/components/marks/Mark.jsx b/packages/ui/src/components/Plot/components/marks/Mark.jsx index 1f1a77827..7df2089ee 100644 --- a/packages/ui/src/components/Plot/components/marks/Mark.jsx +++ b/packages/ui/src/components/Plot/components/marks/Mark.jsx @@ -1,3 +1,5 @@ +import { memo } from "react"; +import { useVis } from "../../contexts/VisContext"; import { usePlot } from "../../contexts/PlotContext"; import { useFrame } from "../../contexts/FrameContext"; import { fromFrameOrPlot } from "../../util/fromFrameOrPlot"; @@ -5,26 +7,56 @@ import { isIterable } from "../../util/helpers"; import { finalData } from "../../util/finalData"; import { processAccessors } from "../../util/processAccessors"; import { rowValues } from "../../util/rowValues"; +import { Field } from "../../../ProfileHeader"; +import { OTHER } from "../../util/constants"; -export default function Mark({ +export default memo(function Mark({ data, + dataFrom, missing, + hover, accessors, markChannels, - createMark + tagName, + createAttrs, }) { + console.log(tagName); + + const vis = useVis(); + if ((dataFrom || hover) && !vis) { + throw Error("dataFrom and hover props can only be used inside a VisProvider"); + } + const plot = usePlot(); if (!plot) { throw Error("mark components must appear inside a Plot component"); } + const frame = useFrame(); - const ops = fromFrameOrPlot(['data', 'scales', 'mapX', 'mapY'], frame, plot); const { scales, mapX, mapY } = ops; - // eslint-disable-next-line - data = finalData(ops.data, data); + // get/process + if (dataFrom) { + const parts = dataFrom.trim().split('-'); + const selectionType = parts[0]; + const selectionLabel = parts.slice(1).join('-') || OTHER; + if (selectionType !== 'hover') { + throw Error(`"${selectionType}" is not a valid selection type`); + } + if (data && typeof data !== 'function') { + throw Error( + 'when the dataFrom prop is used, the data prop must be omitted or be a function' + ); + } + const selectedData = vis.getSelection(selectionType, selectionLabel); + // eslint-disable-next-line + data = selectedData ? finalData(selectedData, data) : []; + } else { + // eslint-disable-next-line + data = finalData(ops.data, data); + } if (!isIterable(data)) { throw Error('mark data must be an iterable'); } @@ -42,8 +74,7 @@ export default function Mark({ let rowIndex = 0; for (const d of data) { const row = rowValues({ - // eslint-disable-next-line - rowIndex: rowIndex++, + rowIndex, rowData: d, missing, finalAccessors, @@ -53,8 +84,26 @@ export default function Mark({ }); if (row != null) { - marks.push(createMark(row)); + const attrs = createAttrs(row); + if (hover) { + const selectionLabel = typeof hover === 'string' ? hover : OTHER; + attrs.onMouseEnter = () => vis.setSelection( + 'hover', + selectionLabel, + [d], + ); + attrs.onMouseLeave = () => vis.setSelection( + 'hover', + selectionLabel, + null, + ); + } + marks.push( + + ); } + + rowIndex += 1; } if (marks.length === 0) return null; @@ -65,4 +114,9 @@ export default function Mark({ ); +}); + +function DynamicTag({ tagName, children, ...props }) { + const Tag = tagName; // capitalize to use it as a component + return {children}; } \ No newline at end of file diff --git a/packages/ui/src/components/Plot/components/marks/Segment.jsx b/packages/ui/src/components/Plot/components/marks/Segment.jsx index d94c41c45..858618209 100644 --- a/packages/ui/src/components/Plot/components/marks/Segment.jsx +++ b/packages/ui/src/components/Plot/components/marks/Segment.jsx @@ -1,6 +1,12 @@ import Mark from "./Mark"; -export default function Segment({ data, missing = 'throw', ...accessors }) { +export default function Segment({ + data, + dataFrom, + missing = 'throw', + hover, + ...accessors + }) { const markChannels = [ 'x', @@ -13,11 +19,12 @@ export default function Segment({ data, missing = 'throw', ...accessors }) { 'strokeOpacity', 'strokeWidth', 'strokeCap', - 'strokeDash', + 'strokeDasharray', ]; - let key = 0; - function createMark(row) { + const tagName = 'line'; + + function createAttrs(row) { const attrs = { x1: row.x + row.dx, y1: row.y + row.dy, @@ -28,10 +35,19 @@ export default function Segment({ data, missing = 'throw', ...accessors }) { strokeWidth: row.strokeWidth, }; if (row.strokeCap) attrs.strokeCap = row.strokeCap; - if (row.strokeDash) attrs.strokeDash = row.strokeDash; - // eslint-disable-next-line - return + if (row.strokeDasharray) attrs.strokeDasharray = row.strokeDasharray; + return attrs; } - return ; + return ; + } \ No newline at end of file diff --git a/packages/ui/src/components/Plot/contexts/VisContext.jsx b/packages/ui/src/components/Plot/contexts/VisContext.jsx index 23b1d19f2..0a73e3597 100644 --- a/packages/ui/src/components/Plot/contexts/VisContext.jsx +++ b/packages/ui/src/components/Plot/contexts/VisContext.jsx @@ -1,49 +1,38 @@ -import { createContext, useContext, useReducer } from 'react'; +import { createContext, useContext, useState, useCallback } from 'react'; +import { OTHER } from '../util/constants'; const VisContext = createContext(null); -const VisDispatchContext = createContext(null); export function VisProvider({ children, data = null }) { - const initialState = { - data, - tooltip: null, - }; + let setData; + // eslint-disable-next-line + [data, setData] = useState(data); - const [state, stateDispatch] = useReducer(reducer, initialState); + // use a getter function for selection so only components that depend on + // selection rerender when it changes + const [_selection, _setSelection] = useState({ hover: {} }); + + const getSelection = useCallback((selectionType, selectionLabel = OTHER) => { + return _selection[selectionType][selectionLabel]; + }, [_selection]); + const setSelection = useCallback( + (selectionType, selectionLabel, selectionData) => { + const newSelection = { ..._selection }; + newSelection[selectionType][selectionLabel] = selectionData; + _setSelection(newSelection); + }, + [_selection, _setSelection] + ); return ( - - - {children} - + + {children} ); } export function useVis() { return useContext(VisContext); -} - -export function useVisDispatch() { - return useContext(VisDispatchContext); -} - -// data reducer -function reducer(state, action) { - - switch(action.type) { - - case 'tooltip': { - const newState = { ...state }; - newState.tooltip = action.data; - return newState; - } - - } - - // !! IF ACTION REPLACES DATA, SET tooltip TO {} - AND ANY OTHER - // INTERACTION OBJECTS THAT INTRODUCE - } \ No newline at end of file diff --git a/packages/ui/src/components/Plot/defaults/channelDefaults.js b/packages/ui/src/components/Plot/defaults/channelDefaults.js index df639c259..6a50aa4c8 100644 --- a/packages/ui/src/components/Plot/defaults/channelDefaults.js +++ b/packages/ui/src/components/Plot/defaults/channelDefaults.js @@ -11,7 +11,7 @@ export const channelDefaults = { strokeOpacity: 1, strokeWidth: 0, // !! MUST SET STROKE WIDTHS TO SEE LINES !! strokeCap: null, - strokeDash: null, + strokeDasharray: null, tooltip: null, // special - only used by one/few marks diff --git a/packages/ui/src/components/Plot/index.js b/packages/ui/src/components/Plot/index.js index 926ba4530..2e72c7438 100644 --- a/packages/ui/src/components/Plot/index.js +++ b/packages/ui/src/components/Plot/index.js @@ -1,4 +1,4 @@ -export { VisProvider, useVis, useVisDispatch } from "./contexts/VisContext"; +export { VisProvider, useVis } from "./contexts/VisContext"; export { default as Frame } from "./components/XAxis"; export { default as Panel } from "./components/Panel"; export { default as Plot } from "./components/Plot"; diff --git a/packages/ui/src/components/Plot/util/baseReducer.js b/packages/ui/src/components/Plot/util/baseReducer.js deleted file mode 100644 index 03a04d130..000000000 --- a/packages/ui/src/components/Plot/util/baseReducer.js +++ /dev/null @@ -1,28 +0,0 @@ -export function baseReducer({ state, action, contextName }) { - - if (!Object.hasOwn(state, action.name)) { - throw Error(`${action.name} is not a valid property for ${contextName}`); - } - - let newState = { ...state }; - - switch(action.type) { - - case 'set': - newState[action.name] = action.value; - break; - - // ?? ARE CLEAR AND UPDATE NEEDED ??/ - // case 'clear': - // newState[action.name] = null; - // break; - - // case 'update': - // newState[action.name] = action.value(newState[action.name]); - // break; - - } - - return newState; - -} \ No newline at end of file diff --git a/packages/ui/src/components/Plot/util/constants.js b/packages/ui/src/components/Plot/util/constants.js new file mode 100644 index 000000000..e540d2ae2 --- /dev/null +++ b/packages/ui/src/components/Plot/util/constants.js @@ -0,0 +1 @@ +export const OTHER = '__other__'; \ No newline at end of file From 2e20a765a9acd04d7541dd520937e3b3761daf8c Mon Sep 17 00:00:00 2001 From: Graham McNeill Date: Mon, 11 Nov 2024 21:34:01 +0000 Subject: [PATCH 10/16] redesign vis provider --- .../src/study/GWASCredibleSets/Body.tsx | 14 +- packages/ui/src/components/Plot/README.md | 3 +- .../components/Plot/components/marks/Mark.jsx | 127 +----------------- .../Plot/components/marks/SelectionMark.jsx | 95 +++++++++++++ .../Plot/components/marks/StandardMark.jsx | 99 ++++++++++++++ .../Plot/components/util/DynamicTag.jsx | 4 + .../components/Plot/contexts/PlotContext.jsx | 5 - .../components/Plot/contexts/VisContext.jsx | 55 ++++---- packages/ui/src/components/Plot/index.js | 6 +- 9 files changed, 247 insertions(+), 161 deletions(-) create mode 100644 packages/ui/src/components/Plot/components/marks/SelectionMark.jsx create mode 100644 packages/ui/src/components/Plot/components/marks/StandardMark.jsx create mode 100644 packages/ui/src/components/Plot/components/util/DynamicTag.jsx diff --git a/packages/sections/src/study/GWASCredibleSets/Body.tsx b/packages/sections/src/study/GWASCredibleSets/Body.tsx index 26329ae0e..bdf0be77e 100644 --- a/packages/sections/src/study/GWASCredibleSets/Body.tsx +++ b/packages/sections/src/study/GWASCredibleSets/Body.tsx @@ -198,7 +198,7 @@ function ManhattanPlot({ data }) { const markColor = '#3489ca'; return ( - // + genomePositions[d.variant.id]} @@ -247,11 +247,11 @@ function ManhattanPlot({ data }) { stroke={markColor} strokeWidth={1.2} area={24} - // hover + hover /> {/* HOVER TETST */} - {/* genomePositions[d.variant.id]} y={pValue} @@ -259,11 +259,11 @@ function ManhattanPlot({ data }) { fillOpacity={1} stroke={markColor} strokeWidth={1.2} - area={64} - /> */} + area={24} + /> - // + ); } diff --git a/packages/ui/src/components/Plot/README.md b/packages/ui/src/components/Plot/README.md index c8b544986..38b6350f3 100644 --- a/packages/ui/src/components/Plot/README.md +++ b/packages/ui/src/components/Plot/README.md @@ -1,6 +1,5 @@ # TO DO -- vis provider: use separate contexts for setting and getting the selected data - and have standard (can set selected data) and 'reactive' (can consume selected data) to avoid rerendering all marks on e.g. hover. (and remove any senseless memoing!) - scales: - allow `scales` to be function which takes the `data` prop (or passed down data) and returns an object so that can use the data to compute the scales - shorthand for linear scales: e.g. `x={[10, 40]}`. @@ -17,6 +16,7 @@ - if error because no `MapX` or `mapY` is it clear that missing scale is the reason? - legend - wrap axis, ticks, ... components in memo - so only change when their props change. They will still change when/if the contexts they use change anyway (as they should) +- import local files from index where can to clean up -------- @@ -241,6 +241,7 @@ POSSIBLE!!: - custom marks - e.g.custom shapes or images for points. - since data can be any iterable, should also allow tick values (when actually used since can be transformed by `values`) to be any iterable rather than just an array - end channels: front, facet (or row/column), ... +- larger capture zone for hover/selection of mark ## Examples/Tests diff --git a/packages/ui/src/components/Plot/components/marks/Mark.jsx b/packages/ui/src/components/Plot/components/marks/Mark.jsx index 7df2089ee..6ec715466 100644 --- a/packages/ui/src/components/Plot/components/marks/Mark.jsx +++ b/packages/ui/src/components/Plot/components/marks/Mark.jsx @@ -1,122 +1,9 @@ -import { memo } from "react"; -import { useVis } from "../../contexts/VisContext"; -import { usePlot } from "../../contexts/PlotContext"; -import { useFrame } from "../../contexts/FrameContext"; -import { fromFrameOrPlot } from "../../util/fromFrameOrPlot"; -import { isIterable } from "../../util/helpers"; -import { finalData } from "../../util/finalData"; -import { processAccessors } from "../../util/processAccessors"; -import { rowValues } from "../../util/rowValues"; -import { Field } from "../../../ProfileHeader"; -import { OTHER } from "../../util/constants"; +import StandardMark from "./StandardMark"; +import SelectionMark from "./SelectionMark"; -export default memo(function Mark({ - data, - dataFrom, - missing, - hover, - accessors, - markChannels, - tagName, - createAttrs, - }) { +export default function Mark(props) { + return props.dataFrom + ? + : ; - console.log(tagName); - - const vis = useVis(); - if ((dataFrom || hover) && !vis) { - throw Error("dataFrom and hover props can only be used inside a VisProvider"); - } - - const plot = usePlot(); - if (!plot) { - throw Error("mark components must appear inside a Plot component"); - } - - const frame = useFrame(); - const ops = fromFrameOrPlot(['data', 'scales', 'mapX', 'mapY'], frame, plot); - const { scales, mapX, mapY } = ops; - - // get/process - if (dataFrom) { - const parts = dataFrom.trim().split('-'); - const selectionType = parts[0]; - const selectionLabel = parts.slice(1).join('-') || OTHER; - if (selectionType !== 'hover') { - throw Error(`"${selectionType}" is not a valid selection type`); - } - if (data && typeof data !== 'function') { - throw Error( - 'when the dataFrom prop is used, the data prop must be omitted or be a function' - ); - } - const selectedData = vis.getSelection(selectionType, selectionLabel); - // eslint-disable-next-line - data = selectedData ? finalData(selectedData, data) : []; - } else { - // eslint-disable-next-line - data = finalData(ops.data, data); - } - if (!isIterable(data)) { - throw Error('mark data must be an iterable'); - } - - const finalAccessors = processAccessors({ - markChannels, - accessors, - scales, - mapX, - mapY, - }); - - const marks = []; - - let rowIndex = 0; - for (const d of data) { - const row = rowValues({ - rowIndex, - rowData: d, - missing, - finalAccessors, - scales, - mapX, - mapY, - }); - - if (row != null) { - const attrs = createAttrs(row); - if (hover) { - const selectionLabel = typeof hover === 'string' ? hover : OTHER; - attrs.onMouseEnter = () => vis.setSelection( - 'hover', - selectionLabel, - [d], - ); - attrs.onMouseLeave = () => vis.setSelection( - 'hover', - selectionLabel, - null, - ); - } - marks.push( - - ); - } - - rowIndex += 1; - } - - if (marks.length === 0) return null; - - return ( - - {marks} - - ); - -}); - -function DynamicTag({ tagName, children, ...props }) { - const Tag = tagName; // capitalize to use it as a component - return {children}; -} \ No newline at end of file +}; \ No newline at end of file diff --git a/packages/ui/src/components/Plot/components/marks/SelectionMark.jsx b/packages/ui/src/components/Plot/components/marks/SelectionMark.jsx new file mode 100644 index 000000000..050834973 --- /dev/null +++ b/packages/ui/src/components/Plot/components/marks/SelectionMark.jsx @@ -0,0 +1,95 @@ +import { memo } from "react"; +import { useVisSelection } from "../../contexts/VisContext"; +import { usePlot } from "../../contexts/PlotContext"; +import { useFrame } from "../../contexts/FrameContext"; +import { fromFrameOrPlot } from "../../util/fromFrameOrPlot"; +import { isIterable } from "../../util/helpers"; +import { finalData } from "../../util/finalData"; +import { processAccessors } from "../../util/processAccessors"; +import { rowValues } from "../../util/rowValues"; +import { OTHER } from "../../util/constants"; +import DynamicTag from "../util/DynamicTag"; + +export default memo(function SelectionMark({ + data, + dataFrom, // SelectionMark is only used the dataFrom prop is used + missing, + accessors, + markChannels, + tagName, + createAttrs, + }) { + + // console.log(`selection mark render: ${tagName}`); + + const visSelection = useVisSelection(); + if (!visSelection) { + throw Error("the dataFrom prop can only be used inside a VisProvider"); + } + + const plot = usePlot(); + if (!plot) { + throw Error("mark components must appear inside a Plot component"); + } + + const frame = useFrame(); + const ops = fromFrameOrPlot(['data', 'scales', 'mapX', 'mapY'], frame, plot); + const { scales, mapX, mapY } = ops; + + const parts = dataFrom.trim().split('-'); + const selectionType = parts[0]; + const selectionLabel = parts.slice(1).join('-') || OTHER; + if (selectionType !== 'hover') { + throw Error(`"${selectionType}" is not a valid selection type`); + } + if (data && typeof data !== 'function') { + throw Error( + 'when the dataFrom prop is used, the data prop must be omitted or be a function' + ); + } + const selectedData = visSelection[selectionType][selectionLabel]; + // eslint-disable-next-line + data = selectedData ? finalData(selectedData, data) : []; + + const finalAccessors = processAccessors({ + markChannels, + accessors, + scales, + mapX, + mapY, + }); + + const marks = []; + + let rowIndex = 0; + for (const d of data) { + const row = rowValues({ + rowIndex, + rowData: d, + missing, + finalAccessors, + scales, + mapX, + mapY, + }); + + if (row != null) { + const attrs = createAttrs(row); + attrs.pointerEvents = "none"; + marks.push( + + ); + } + + rowIndex += 1; + } + + if (marks.length === 0) return null; + + return ( + + {marks} + + ); + +}); \ No newline at end of file diff --git a/packages/ui/src/components/Plot/components/marks/StandardMark.jsx b/packages/ui/src/components/Plot/components/marks/StandardMark.jsx new file mode 100644 index 000000000..908b79be6 --- /dev/null +++ b/packages/ui/src/components/Plot/components/marks/StandardMark.jsx @@ -0,0 +1,99 @@ +import { memo } from "react"; +import { useVisUpdateSelection } from "../../contexts/VisContext"; +import { usePlot } from "../../contexts/PlotContext"; +import { useFrame } from "../../contexts/FrameContext"; +import { fromFrameOrPlot } from "../../util/fromFrameOrPlot"; +import { isIterable } from "../../util/helpers"; +import { finalData } from "../../util/finalData"; +import { processAccessors } from "../../util/processAccessors"; +import { rowValues } from "../../util/rowValues"; +import { OTHER } from "../../util/constants"; +import DynamicTag from "../util/DynamicTag"; + +export default memo(function StandardMark({ + data, + missing, + hover, + accessors, + markChannels, + tagName, + createAttrs, + }) { + + // console.log(`standard mark render: ${tagName}`); + + const visUpdateSelection = useVisUpdateSelection(); + if (hover && !visUpdateSelection) { + throw Error("hover props can only be used inside a VisProvider"); + } + + const plot = usePlot(); + if (!plot) { + throw Error("mark components must appear inside a Plot component"); + } + + const frame = useFrame(); + const ops = fromFrameOrPlot(['data', 'scales', 'mapX', 'mapY'], frame, plot); + const { scales, mapX, mapY } = ops; + + // eslint-disable-next-line + data = finalData(ops.data, data); + if (!isIterable(data)) { + throw Error('mark data must be an iterable'); + } + + const finalAccessors = processAccessors({ + markChannels, + accessors, + scales, + mapX, + mapY, + }); + + const marks = []; + + let rowIndex = 0; + for (const d of data) { + const row = rowValues({ + rowIndex, + rowData: d, + missing, + finalAccessors, + scales, + mapX, + mapY, + }); + + if (row != null) { + const attrs = createAttrs(row); + + if (hover) { + const selectionLabel = typeof hover === 'string' ? hover : OTHER; + attrs.onMouseEnter = () => visUpdateSelection( + 'hover', + selectionLabel, + [d], + ); + attrs.onMouseLeave = () => visUpdateSelection( + 'hover', + selectionLabel, + null, + ); + } + marks.push( + + ); + } + + rowIndex += 1; + } + + if (marks.length === 0) return null; + + return ( + + {marks} + + ); + +}); \ No newline at end of file diff --git a/packages/ui/src/components/Plot/components/util/DynamicTag.jsx b/packages/ui/src/components/Plot/components/util/DynamicTag.jsx new file mode 100644 index 000000000..3313d2e3d --- /dev/null +++ b/packages/ui/src/components/Plot/components/util/DynamicTag.jsx @@ -0,0 +1,4 @@ +export default function DynamicTag({ tagName, children, ...props }) { + const Tag = tagName; // capitalize to use it as a component + return {children}; + } \ No newline at end of file diff --git a/packages/ui/src/components/Plot/contexts/PlotContext.jsx b/packages/ui/src/components/Plot/contexts/PlotContext.jsx index 24ab6ff57..0f842e0cd 100644 --- a/packages/ui/src/components/Plot/contexts/PlotContext.jsx +++ b/packages/ui/src/components/Plot/contexts/PlotContext.jsx @@ -1,8 +1,6 @@ import { createContext, useContext, useReducer } from 'react'; -import { useVis } from './VisContext'; import { safeAssign } from '../util/helpers'; -import { finalData } from '../util/finalData'; import { plotDefaults } from '../defaults/plotDefaults'; import { addXYMaps } from '../util/addXYMaps'; import { onlyValidScales } from '../util/assert'; @@ -12,9 +10,7 @@ const PlotDispatchContext = createContext(null); export function PlotProvider({ children, options }) { - const vis = useVis(); const initialState = safeAssign({ ...plotDefaults }, options); - initialState.data = finalData(vis?.data, initialState.data); // compute values related to plot size and panel spacing let { padding } = initialState; @@ -46,7 +42,6 @@ export function usePlotDispatch() { return useContext(PlotDispatchContext); } -// data reducer function reducer(state, action) { switch(action.type) { diff --git a/packages/ui/src/components/Plot/contexts/VisContext.jsx b/packages/ui/src/components/Plot/contexts/VisContext.jsx index 0a73e3597..849344008 100644 --- a/packages/ui/src/components/Plot/contexts/VisContext.jsx +++ b/packages/ui/src/components/Plot/contexts/VisContext.jsx @@ -1,38 +1,39 @@ - import { createContext, useContext, useState, useCallback } from 'react'; -import { OTHER } from '../util/constants'; - -const VisContext = createContext(null); -export function VisProvider({ children, data = null }) { - - let setData; - // eslint-disable-next-line - [data, setData] = useState(data); +const SelectionContext = createContext(null); +const UpdateSelectionContext = createContext(null); - // use a getter function for selection so only components that depend on - // selection rerender when it changes - const [_selection, _setSelection] = useState({ hover: {} }); - - const getSelection = useCallback((selectionType, selectionLabel = OTHER) => { - return _selection[selectionType][selectionLabel]; - }, [_selection]); - const setSelection = useCallback( +export function VisProvider({ children }) { + const [selection, setSelection] = useState({ hover: {} }); + const updateSelection = useCallback( (selectionType, selectionLabel, selectionData) => { - const newSelection = { ..._selection }; - newSelection[selectionType][selectionLabel] = selectionData; - _setSelection(newSelection); + const newSelection = { ...selection }; + if (selectionType === 'hover') { + const currentData = newSelection[selectionType][selectionLabel]; + if (currentData === selectionData || + currentData?.[0] === selectionData?.[0]) { + return; + } + newSelection[selectionType][selectionLabel] = selectionData; + setSelection(newSelection); + } }, - [_selection, _setSelection] + [] ); return ( - - {children} - + + + {children} + + ); } - -export function useVis() { - return useContext(VisContext); + +export function useVisSelection() { + return useContext(SelectionContext); +} + +export function useVisUpdateSelection() { + return useContext(UpdateSelectionContext); } \ No newline at end of file diff --git a/packages/ui/src/components/Plot/index.js b/packages/ui/src/components/Plot/index.js index 2e72c7438..9c3ee1cba 100644 --- a/packages/ui/src/components/Plot/index.js +++ b/packages/ui/src/components/Plot/index.js @@ -1,4 +1,8 @@ -export { VisProvider, useVis } from "./contexts/VisContext"; +export { + VisProvider, + useVisSelection, + useVisUpdateSelection, +} from "./contexts/VisContext"; export { default as Frame } from "./components/XAxis"; export { default as Panel } from "./components/Panel"; export { default as Plot } from "./components/Plot"; From b3bd2b94b17319180c9ff68b34ea493f11750ab8 Mon Sep 17 00:00:00 2001 From: Graham McNeill Date: Tue, 12 Nov 2024 10:00:22 +0000 Subject: [PATCH 11/16] cleanup --- .../src/study/GWASCredibleSets/Body.tsx | 8 +-- packages/ui/src/components/Plot/README.md | 60 +++++++------------ .../src/components/Plot/components/Panel.jsx | 1 - .../Plot/components/marks/Circle.jsx | 2 + .../Plot/components/marks/Segment.jsx | 2 + .../Plot/components/marks/SelectionMark.jsx | 8 +-- .../Plot/components/marks/StandardMark.jsx | 5 +- .../components/Plot/contexts/VisContext.jsx | 3 +- .../Plot/defaults/channelDefaults.js | 2 +- packages/ui/src/components/Plot/index.js | 2 +- 10 files changed, 37 insertions(+), 56 deletions(-) diff --git a/packages/sections/src/study/GWASCredibleSets/Body.tsx b/packages/sections/src/study/GWASCredibleSets/Body.tsx index bdf0be77e..c3d859392 100644 --- a/packages/sections/src/study/GWASCredibleSets/Body.tsx +++ b/packages/sections/src/study/GWASCredibleSets/Body.tsx @@ -16,7 +16,7 @@ import { XGrid, Circle, Segment, - VisProvider, + Vis, } from "ui"; import { naLabel } from "../../constants"; import { definition } from "."; @@ -198,7 +198,7 @@ function ManhattanPlot({ data }) { const markColor = '#3489ca'; return ( - + - + ); } diff --git a/packages/ui/src/components/Plot/README.md b/packages/ui/src/components/Plot/README.md index 38b6350f3..cb4a7be71 100644 --- a/packages/ui/src/components/Plot/README.md +++ b/packages/ui/src/components/Plot/README.md @@ -20,11 +20,11 @@ -------- -## Vis Provider +## Vis -If drawing multiple related plots, wrap them in a ``. This provides a single location where data for the plots can be added. +For an interactive plot - e.g. to use the §hover§ prop of marks - wrap the plot in a `` component. This provides a context which is used internally to set and access selected data. -A vis provider is also required for interactive plots - even a single interactive plot. For multi-plot visualisations, the vis provider makes it easy to e.g. hover on a point in one plot and highlight the corresponding points in the other plots. It's also fine to include non-plot content in the provider - this content can import and use the context provided by `VisProvider` like any other React context. +We can also wrap multiple plots in a single `Vis` component to coordinate interaction across the plots. For example, hovering on a point in one plot and highlight the corresponding point in another plot. It's also possible to include non-plot content in the `Vis`. In this case the `useVisSelection` and `useVisUpdateSelection` hooks can be used explicitly to get/set selected data. > Note: CSS should be used to layout groups of plots - flex or grid is typically the most useful. @@ -38,43 +38,15 @@ Use the `responsive` prop of `Plot` (no value required) to have the width of the ## Frame -Use a `Frame` to use different scales (for the same channel) on the same plot. +Use a `Frame` to use different scales on the same plot. -Frames allow us to overlay multiple plots on the same panel. A frame goes inside a plot element but can take its `data`, `scales`, `xTick`, `yTick`, `xReverse` and `yReverse` props - where used, these override those inherited from the plot. +Frames allow us to overlay multiple plots on the same panel. A frame goes inside a plot element but can take its own `data`, `scales`, `xTick`, `yTick`, `xReverse` and `yReverse` props - where used, these override those inherited from the plot. A frame can contain any of the components that a plot can contain - except for another frame. -In the following example, we use a frame to display a second y-axis that shows the weight in pounds (where 1 kg = 2.2 lbs): - -```jsx - - - - Height (cm) - - - Weight (kg) - - - - Weight (lbs) - - d.height_cm} y={d => d.weight_kg} /> - -``` - ### Panel -Conceptually, a plot contains a reactangular _panel_ where the marks are drawn. Any axes are shown along the side/top/bottom of the panel, but are outside the panel. To style this panel, include a `` component. The following props can be used to style the panel: - -| Prop | Default | -|----------|-------------| -| `background` | `left-aligned` | -| `borderWidth` | `0` | -| `borderColor` | `#888` | +A plot's marks are drawn in a rectangular _panel_ computed from the the plot's size and padding. Including a `` inside a plot adds a `` at the correct size and position. Use props such as `fill` to style the rectangle - all props passed to the `Panel` are passed directly to the `rect`. ### Scales @@ -119,14 +91,13 @@ The behavior for y ticks is identical to that of x ticks. ## Data -Data flows through a visualisation `VisProvider` -> `Plot` -> `Frame` -> plot components. In many cases, `VisProvider` and `Frame` are omitted. +Data flows through a visualisation: `Plot` -> (`Frame` ->) mark components. -Each of `VisProvider`, `Plot`, `Frame` as well as mark components such as `Circle` can take a `data` prop. This can be a data set or a function. A function is passed the data from the component above and should return a transformed data set for the component. +Any of these components can take a `data` prop. The `data` component can be a data set or a function that is passed the data from the component above and should return a transformed data set for the component. If the `data` prop is not used, data is passed down from the parent component as is. -> Note: The data used by mark components such as `` must be an iterable. A `` can still use data passed down to it that is not iterable, but the `data` prop must be used to transform the data into an iterable. - +The data used by mark components such as `` must be an iterable. A `` can still use data passed down to it that is not iterable, but the `data` prop must be used to transform the data into an iterable. ## Marks @@ -161,6 +132,10 @@ __NOT IMPLEMENTED__ | hBand | horizontal band | | vBand | vertical band | +### Missing Data + +TODO: NAN AND INIFINITE THROW, NULL/UNDEFINED HANDLED BY MISSING PROP OF MARKS + ## Channels _Accessor functions_ are used to map data to channels. Each data point is passed to the accessor function along with its index; the function returns the value for that channel. Accessor functions are often very simple, e.g. `x={d => d.year}`. @@ -202,14 +177,20 @@ To draw a single mark with all constant channels, use a data set of length 1: /> ``` +## Interaction + +TODO: ONLY HOVER CURRENTLY +- USE HOVER PROP IN NORMAL MARKS - VALUE CAN BE OMITTED OR STRING NAME TO REFER TO GROUP +- USE DATAFROM INSTEAD OF DATA IN MARK TO SHOW ON HOVER + ------ Notes: +- contexts: plot and frame are standard: everything will be redrawn when change anythng, but OK since will rarely change. Vis context split into get/set so that nothing redrawn on set and only selection marks drawn on get - left out YTitle for now since rarely use old school rotated y axis title anymore. Can use an XTitle with position='top' and textAnchor='end' - currently always using indices for keys - may need to revisit this when think about animation, interaction, ... - we can pass arbitrary attr values to ticks, labels etc, but not to marks - since all 'other props' are interpreted as channels. Can/should we allow passing arb attr values through to the svg element representing the mark? -- `ResposiveContainer` is currently only respsonsive on horizontal changes - `responsive` prop: - currently only repsonsive for width, but easy to make responsive on height since could use same pattern as for width and the `updateSize` action used with the plot context already handles height changes - better design would be to separate dimensions and allow `width="responsive"` and `height="responsive"` @@ -218,6 +199,7 @@ Notes: Add to docs above - padding (on axis, ticks, ...) pushes them away from panel whereas dx,dy props are always in pixels and +ve x to right, +ve y downwards - use e.g. `stroke` to change color in `XTick` - even though there is `tickColor` in defaults, this is not a prop +- API: components and props POSSIBLE!!: - border and cornerradius for the plot? - just as have for the panel diff --git a/packages/ui/src/components/Plot/components/Panel.jsx b/packages/ui/src/components/Plot/components/Panel.jsx index d02e17406..fe813a107 100644 --- a/packages/ui/src/components/Plot/components/Panel.jsx +++ b/packages/ui/src/components/Plot/components/Panel.jsx @@ -7,7 +7,6 @@ export default function Panel(rectAttrs) { throw Error("Panel component must appear inside a Plot component"); } - const { panelWidth, panelHeight, padding} = plot; return ( diff --git a/packages/ui/src/components/Plot/components/marks/Circle.jsx b/packages/ui/src/components/Plot/components/marks/Circle.jsx index d1f2db290..5dd1dbcf9 100644 --- a/packages/ui/src/components/Plot/components/marks/Circle.jsx +++ b/packages/ui/src/components/Plot/components/marks/Circle.jsx @@ -21,6 +21,7 @@ export default function Circle({ 'strokeCap', 'strokeDasharray', 'area', + 'pointerEvents', ]; const tagName = 'circle'; @@ -38,6 +39,7 @@ export default function Circle({ }; if (row.strokeCap) attrs.strokeCap = row.strokeCap; if (row.strokeDasharray) attrs.strokeDasharray = row.strokeDasharray; + if (row.pointerEvents) attrs.pointerEvents = row.pointerEvents; return attrs; } diff --git a/packages/ui/src/components/Plot/components/marks/Segment.jsx b/packages/ui/src/components/Plot/components/marks/Segment.jsx index 858618209..f5268354e 100644 --- a/packages/ui/src/components/Plot/components/marks/Segment.jsx +++ b/packages/ui/src/components/Plot/components/marks/Segment.jsx @@ -20,6 +20,7 @@ export default function Segment({ 'strokeWidth', 'strokeCap', 'strokeDasharray', + 'pointerEvents', ]; const tagName = 'line'; @@ -36,6 +37,7 @@ export default function Segment({ }; if (row.strokeCap) attrs.strokeCap = row.strokeCap; if (row.strokeDasharray) attrs.strokeDasharray = row.strokeDasharray; + if (row.pointerEvents) attrs.pointerEvents = row.pointerEvents; return attrs; } diff --git a/packages/ui/src/components/Plot/components/marks/SelectionMark.jsx b/packages/ui/src/components/Plot/components/marks/SelectionMark.jsx index 050834973..17ab81193 100644 --- a/packages/ui/src/components/Plot/components/marks/SelectionMark.jsx +++ b/packages/ui/src/components/Plot/components/marks/SelectionMark.jsx @@ -12,7 +12,7 @@ import DynamicTag from "../util/DynamicTag"; export default memo(function SelectionMark({ data, - dataFrom, // SelectionMark is only used the dataFrom prop is used + dataFrom, // SelectionMark is only used when the dataFrom prop is used missing, accessors, markChannels, @@ -20,11 +20,9 @@ export default memo(function SelectionMark({ createAttrs, }) { - // console.log(`selection mark render: ${tagName}`); - const visSelection = useVisSelection(); if (!visSelection) { - throw Error("the dataFrom prop can only be used inside a VisProvider"); + throw Error("the dataFrom prop can only be used inside a Vis component"); } const plot = usePlot(); @@ -75,7 +73,7 @@ export default memo(function SelectionMark({ if (row != null) { const attrs = createAttrs(row); - attrs.pointerEvents = "none"; + attrs.pointerEvents ??= "none"; marks.push( ); diff --git a/packages/ui/src/components/Plot/components/marks/StandardMark.jsx b/packages/ui/src/components/Plot/components/marks/StandardMark.jsx index 908b79be6..ab218d18f 100644 --- a/packages/ui/src/components/Plot/components/marks/StandardMark.jsx +++ b/packages/ui/src/components/Plot/components/marks/StandardMark.jsx @@ -20,11 +20,9 @@ export default memo(function StandardMark({ createAttrs, }) { - // console.log(`standard mark render: ${tagName}`); - const visUpdateSelection = useVisUpdateSelection(); if (hover && !visUpdateSelection) { - throw Error("hover props can only be used inside a VisProvider"); + throw Error("hover props can only be used inside a Vis component"); } const plot = usePlot(); @@ -80,6 +78,7 @@ export default memo(function StandardMark({ null, ); } + marks.push( ); diff --git a/packages/ui/src/components/Plot/contexts/VisContext.jsx b/packages/ui/src/components/Plot/contexts/VisContext.jsx index 849344008..e30b0c687 100644 --- a/packages/ui/src/components/Plot/contexts/VisContext.jsx +++ b/packages/ui/src/components/Plot/contexts/VisContext.jsx @@ -3,7 +3,8 @@ import { createContext, useContext, useState, useCallback } from 'react'; const SelectionContext = createContext(null); const UpdateSelectionContext = createContext(null); -export function VisProvider({ children }) { +// provider +export function Vis({ children }) { const [selection, setSelection] = useState({ hover: {} }); const updateSelection = useCallback( (selectionType, selectionLabel, selectionData) => { diff --git a/packages/ui/src/components/Plot/defaults/channelDefaults.js b/packages/ui/src/components/Plot/defaults/channelDefaults.js index 6a50aa4c8..787e96f73 100644 --- a/packages/ui/src/components/Plot/defaults/channelDefaults.js +++ b/packages/ui/src/components/Plot/defaults/channelDefaults.js @@ -12,7 +12,7 @@ export const channelDefaults = { strokeWidth: 0, // !! MUST SET STROKE WIDTHS TO SEE LINES !! strokeCap: null, strokeDasharray: null, - tooltip: null, + pointerEvents: null, // though 'none' is the default for selection marks // special - only used by one/few marks xx: 0, // HBar, Segment, HLink, VLink, Edge, VBand diff --git a/packages/ui/src/components/Plot/index.js b/packages/ui/src/components/Plot/index.js index 9c3ee1cba..2e20805a0 100644 --- a/packages/ui/src/components/Plot/index.js +++ b/packages/ui/src/components/Plot/index.js @@ -1,5 +1,5 @@ export { - VisProvider, + Vis, useVisSelection, useVisUpdateSelection, } from "./contexts/VisContext"; From b2d0e5df33b73a6b5cdff067d78470807ebab652 Mon Sep 17 00:00:00 2001 From: Graham McNeill Date: Tue, 12 Nov 2024 10:52:00 +0000 Subject: [PATCH 12/16] draft text and hover --- .../src/study/GWASCredibleSets/Body.tsx | 30 +++++++- .../Plot/components/marks/SelectionMark.jsx | 5 +- .../Plot/components/marks/StandardMark.jsx | 5 +- .../components/Plot/components/marks/Text.jsx | 73 +++++++++++++++++++ .../Plot/defaults/channelDefaults.js | 3 +- packages/ui/src/components/Plot/index.js | 3 +- 6 files changed, 110 insertions(+), 9 deletions(-) create mode 100644 packages/ui/src/components/Plot/components/marks/Text.jsx diff --git a/packages/sections/src/study/GWASCredibleSets/Body.tsx b/packages/sections/src/study/GWASCredibleSets/Body.tsx index c3d859392..985d2cb72 100644 --- a/packages/sections/src/study/GWASCredibleSets/Body.tsx +++ b/packages/sections/src/study/GWASCredibleSets/Body.tsx @@ -16,6 +16,7 @@ import { XGrid, Circle, Segment, + Text, Vis, } from "ui"; import { naLabel } from "../../constants"; @@ -243,21 +244,42 @@ function ManhattanPlot({ data }) { x={d => genomePositions[d.variant.id]} y={pValue} fill={background} - fillOpacity={1} stroke={markColor} strokeWidth={1.2} - area={24} + area={30} hover /> {/* HOVER TETST */} + genomePositions[d.variant.id]} + xx={d => genomePositions[d.variant.id]} + y={pValue} + yy={pValueMax} + fill="transparent" + stroke={markColor} + strokeWidth={1.7} + strokeOpacity={0.7} + area={24} + hover + /> genomePositions[d.variant.id]} y={pValue} fill={markColor} - fillOpacity={1} - area={24} + area={64} + /> + genomePositions[d.variant.id]} + y={pValue} + dy={-14} + fontSize={12} + fontWeight={700} + text={d => d.variant.id} + fill={markColor} /> diff --git a/packages/ui/src/components/Plot/components/marks/SelectionMark.jsx b/packages/ui/src/components/Plot/components/marks/SelectionMark.jsx index 17ab81193..14f4435dc 100644 --- a/packages/ui/src/components/Plot/components/marks/SelectionMark.jsx +++ b/packages/ui/src/components/Plot/components/marks/SelectionMark.jsx @@ -18,6 +18,7 @@ export default memo(function SelectionMark({ markChannels, tagName, createAttrs, + createContent, }) { const visSelection = useVisSelection(); @@ -75,7 +76,9 @@ export default memo(function SelectionMark({ const attrs = createAttrs(row); attrs.pointerEvents ??= "none"; marks.push( - + + {createContent?.(row)} + ); } diff --git a/packages/ui/src/components/Plot/components/marks/StandardMark.jsx b/packages/ui/src/components/Plot/components/marks/StandardMark.jsx index ab218d18f..4a7ac06c5 100644 --- a/packages/ui/src/components/Plot/components/marks/StandardMark.jsx +++ b/packages/ui/src/components/Plot/components/marks/StandardMark.jsx @@ -18,6 +18,7 @@ export default memo(function StandardMark({ markChannels, tagName, createAttrs, + createContent, }) { const visUpdateSelection = useVisUpdateSelection(); @@ -80,7 +81,9 @@ export default memo(function StandardMark({ } marks.push( - + + {createContent?.(row)} + ); } diff --git a/packages/ui/src/components/Plot/components/marks/Text.jsx b/packages/ui/src/components/Plot/components/marks/Text.jsx new file mode 100644 index 000000000..85867fcfa --- /dev/null +++ b/packages/ui/src/components/Plot/components/marks/Text.jsx @@ -0,0 +1,73 @@ +import Mark from "./Mark"; + +export default function Text({ + data, + dataFrom, + missing = 'throw', + hover, + ...accessors + }) { + + const markChannels = [ + 'x', + 'y', + 'dx', + 'dy', + 'fill', + 'fillOpacity', + 'text', + 'fontFamily', + 'fontSize', + 'fontStyle', + 'fontWeight', + 'textAnchor', + 'dominantBaseline', + 'transformOrigin', + 'transformBox', + 'transform', + 'pointerEvents', + ]; + + const tagName = 'text'; + + function createAttrs(row) { + const style = { + transformOrigin: row.transformOrigin, + transformBox: row.transformBox, + } + if (row.transform) style.transform = row.transform; + + const attrs = { + x: row.x + row.dx, + y: row.y + row.dy, + fill: row.fill, + fillOpacity: row.fillOpacity, + fontFamily: row.fontFamily, + fontSize: row.fontSize, + fontStyle: row.fontStyle, + fontWeight: row.fontWeight, + textAnchor: row.textAnchor, + dominantBaseline: row.dominantBaseline, + style, + }; + if (row.pointerEvents) attrs.pointerEvents = row.pointerEvents; + return attrs; + } + + function createContent(row) { + return row.text; + } + + return ; + +} \ No newline at end of file diff --git a/packages/ui/src/components/Plot/defaults/channelDefaults.js b/packages/ui/src/components/Plot/defaults/channelDefaults.js index 787e96f73..527415e1c 100644 --- a/packages/ui/src/components/Plot/defaults/channelDefaults.js +++ b/packages/ui/src/components/Plot/defaults/channelDefaults.js @@ -6,7 +6,7 @@ export const channelDefaults = { dx: 0, // pixels dy: 0, // pixels fill: '#000', - fillOpacity: '0.7', + fillOpacity: 1, stroke: '#000', strokeOpacity: 1, strokeWidth: 0, // !! MUST SET STROKE WIDTHS TO SEE LINES !! @@ -37,5 +37,4 @@ export const channelDefaults = { transformOrigin: 'center', // Text (CSS) transformBox: 'fill-box', // Text (CSS) transform: null, // Text (CSS) - }; \ No newline at end of file diff --git a/packages/ui/src/components/Plot/index.js b/packages/ui/src/components/Plot/index.js index 2e20805a0..872dc1de9 100644 --- a/packages/ui/src/components/Plot/index.js +++ b/packages/ui/src/components/Plot/index.js @@ -16,4 +16,5 @@ export { default as YGrid } from "./components/YGrid"; export { default as YLabel } from "./components/YLabel"; export { default as YTick } from "./components/YTick"; export { default as Circle } from "./components/marks/Circle"; -export { default as Segment } from "./components/marks/Segment"; \ No newline at end of file +export { default as Segment } from "./components/marks/Segment"; +export { default as Text } from "./components/marks/Text"; \ No newline at end of file From 3f5cb0503bc854afb13b06662359a3c71798183f Mon Sep 17 00:00:00 2001 From: Graham McNeill Date: Tue, 12 Nov 2024 17:50:07 +0000 Subject: [PATCH 13/16] clean up manhattan plot --- .../src/study/GWASCredibleSets/Body.tsx | 199 +++++++----------- packages/ui/src/components/Plot/README.md | 2 +- 2 files changed, 81 insertions(+), 120 deletions(-) diff --git a/packages/sections/src/study/GWASCredibleSets/Body.tsx b/packages/sections/src/study/GWASCredibleSets/Body.tsx index 985d2cb72..d91e87862 100644 --- a/packages/sections/src/study/GWASCredibleSets/Body.tsx +++ b/packages/sections/src/study/GWASCredibleSets/Body.tsx @@ -1,4 +1,5 @@ import { useQuery } from "@apollo/client"; +import { Skeleton, useTheme } from "@mui/material"; import { Link, SectionItem, @@ -16,15 +17,13 @@ import { XGrid, Circle, Segment, - Text, - Vis, } from "ui"; import { naLabel } from "../../constants"; import { definition } from "."; import Description from "./Description"; import GWAS_CREDIBLE_SETS_QUERY from "./GWASCredibleSetsQuery.gql"; import { mantissaExponentComparator, variantComparator } from "../../utils/comparators"; -import * as d3 from "d3"; +import { scaleLinear, scaleLog, min } from "d3"; const columns = [ { @@ -150,7 +149,10 @@ function Body({ id, entity }: BodyProps) { renderDescription={() => } renderBody={() => ( <> - + elmt.chromosome === chromosome).start + position; -} - function pValue(row) { return row.pValueMantissa * 10 ** row.pValueExponent; } -function ManhattanPlot({ data }) { +function ManhattanPlot({ loading, data }) { + + const plotHeight = 380; + const theme = useTheme(); + const background = theme.palette.background.paper; + const markColor = theme.palette.primary.main; + const fontFamily = theme.typography.fontFamily; + const circleArea = 24; + if (loading) return ; if (data == null) return null; + const pValueMin = min(data, pValue); + const pValueMax = 1; + const genomePositions = {}; data.forEach(({ variant }) => { genomePositions[variant.id] = cumulativePosition(variant); }); - const pValueMin = d3.min(data, pValue); - const pValueMax = 1; - - const background = '#fff'; - const markColor = '#3489ca'; - return ( - - - tickData.map(chromo => chromo.start)} tickLength={15}/> - - tickData.map(chromo => chromo.midpoint)} - format={(_, i, __, tickData) => tickData[i].chromosome} - padding={6} - /> - tickData.map(chromo => chromo.start)} stroke="#cecece" strokeDasharray="3 4"/> - - -log_10(pValue) - - - - -Math.log10(v)} /> - genomePositions[d.variant.id]} - xx={d => genomePositions[d.variant.id]} - y={pValue} - yy={pValueMax} - fill="transparent" - stroke={markColor} - strokeWidth={1} - strokeOpacity={0.7} - area={24} - hover - /> - genomePositions[d.variant.id]} - y={pValue} - fill={background} - stroke={markColor} - strokeWidth={1.2} - area={30} - hover - /> - - {/* HOVER TETST */} - genomePositions[d.variant.id]} - xx={d => genomePositions[d.variant.id]} - y={pValue} - yy={pValueMax} - fill="transparent" - stroke={markColor} - strokeWidth={1.7} - strokeOpacity={0.7} - area={24} - hover - /> - genomePositions[d.variant.id]} - y={pValue} - fill={markColor} - area={64} - /> - genomePositions[d.variant.id]} - y={pValue} - dy={-14} - fontSize={12} - fontWeight={700} - text={d => d.variant.id} - fill={markColor} - /> - - - + + [0, ...tickData.map(chromo => chromo.end)]} + tickLength={15} + /> + + tickData.map(chromo => chromo.midpoint)} + format={(_, i, __, tickData) => tickData[i].chromosome} + padding={5} + /> + tickData.map(chromo => chromo.end)} + stroke="#cecece" + strokeDasharray="3 4" + /> + + -log + 10 + (pValue) + + {" "}of lead variants + + + + -Math.log10(v)} /> + genomePositions[d.variant.id]} + xx={d => genomePositions[d.variant.id]} + y={pValue} + yy={pValueMax} + stroke={markColor} + strokeWidth={1} + strokeOpacity={0.7} + /> + genomePositions[d.variant.id]} + y={pValue} + fill={background} + stroke={markColor} + strokeWidth={1.2} + area={circleArea} + /> + ); } // ========== chromosome lengths ========== -// !! MOVE THIS TO A DIFFERENT FILE WHEN DONE !! // from: https://www.ncbi.nlm.nih.gov/grc/human/data // (first tab: "Chromosome lengths") const chromosomeInfo = [ @@ -320,7 +292,6 @@ const chromosomeInfo = [ { chromosome: 'Y', length: 57227415 }, ]; -// const cumulativeLengths = [...d3.cumsum(chromosomeInfo, d => d.length)]; chromosomeInfo.forEach((chromo, i) => { chromo.start = chromosomeInfo[i-1]?.end ?? 0; chromo.end = chromo.start + chromo.length; @@ -329,21 +300,11 @@ chromosomeInfo.forEach((chromo, i) => { const genomeLength = chromosomeInfo.at(-1).end; +const chromosomeInfoMap = new Map( + chromosomeInfo.map(obj => [ obj.chromosome, obj ]) +); +function cumulativePosition({ chromosome, position }) { + return chromosomeInfoMap.get(chromosome).start + position; +} -/* ========== TO DO ============================================================ -- prob want plot title e.g. "pValue and position of lead variant of each creidble set" -- only import d3 functions that need -- use subscript for log_10 in x-title -- ideally the circles should show through each other but give circles bgrd colored - so cannot see end off segment in middle of circle - so make segments end at btm of - circle -- show skeleton when plot loading? -- does Manhattan plot need extra props such as loading? - - poss abstract into a PlotWrapper component? - careful as I think already a - component called this in the platform -- need to filter data in case no lead variant - cred set shold always have a lead var? -- ignore data that uses chromo 23 or 24 - see dochoa slack 7/11/24 -- ignore data with no pValue - need to check at top level and within variant? -- properly handle removal of strongestLocusToGene in table in separate PR -*/ \ No newline at end of file diff --git a/packages/ui/src/components/Plot/README.md b/packages/ui/src/components/Plot/README.md index cb4a7be71..ea78e6b63 100644 --- a/packages/ui/src/components/Plot/README.md +++ b/packages/ui/src/components/Plot/README.md @@ -2,7 +2,6 @@ # TO DO - scales: - allow `scales` to be function which takes the `data` prop (or passed down data) and returns an object so that can use the data to compute the scales - - shorthand for linear scales: e.g. `x={[10, 40]}`. - test with discrete scales - remove `strokeDashArray` from `Circle`? - change to `values` as an accessor for things that consume tick values - treating as data is unintuitive even if is more powerful @@ -202,6 +201,7 @@ Add to docs above - API: components and props POSSIBLE!!: +- shorthand for linear scales: e.g. `x={[10, 40]}` - but then need to include d3 as dependency of the plot components - not so bad since clearly require d3 somehow if require d3 scales! - border and cornerradius for the plot? - just as have for the panel - ? HTML inserts - for tooltip, titles, insets, ...? - could have an HTML mark? From b0507fddd3e8591a248c9ae58b1fc4ebb6646005 Mon Sep 17 00:00:00 2001 From: Graham McNeill Date: Wed, 13 Nov 2024 10:20:42 +0000 Subject: [PATCH 14/16] add HTML mark --- packages/ui/src/components/Plot/README.md | 22 +++---- .../components/Plot/components/marks/HTML.jsx | 62 +++++++++++++++++++ .../Plot/components/util/DynamicTag.jsx | 6 +- .../Plot/defaults/channelDefaults.js | 10 +-- packages/ui/src/components/Plot/index.js | 3 +- 5 files changed, 82 insertions(+), 21 deletions(-) create mode 100644 packages/ui/src/components/Plot/components/marks/HTML.jsx diff --git a/packages/ui/src/components/Plot/README.md b/packages/ui/src/components/Plot/README.md index ea78e6b63..75707b81b 100644 --- a/packages/ui/src/components/Plot/README.md +++ b/packages/ui/src/components/Plot/README.md @@ -1,14 +1,9 @@ # TO DO +- NEXT: finish and check HTML - need to complete anchor code - scales: - allow `scales` to be function which takes the `data` prop (or passed down data) and returns an object so that can use the data to compute the scales - test with discrete scales -- remove `strokeDashArray` from `Circle`? -- change to `values` as an accessor for things that consume tick values - treating as data is unintuitive even if is more powerful -- tooltip: - - allow any action when trigger selected so can eg show a MUI tooltip -- making \ accept children is wrong/misleading since is adding contents SVG not HTML elemnt - - how do e.g. subscript? - could make it a foreign object and use HTML? - add remaining marks: can easily add simple marks using the current `Mark`. Will need to extend `Mark` to allow for 'compound marks' such as `Line` that create a single mark from multiple rows. Can do this by adding a `compound` prop to `Mark` and branching on this where create the mark(s) - implement `clip` prop on a mark to clip it to the panel - see https://stackoverflow.com/questions/17388689/svg-clippath-and-transformations - have not implemented `panelSize` prop? @@ -16,12 +11,13 @@ - legend - wrap axis, ticks, ... components in memo - so only change when their props change. They will still change when/if the contexts they use change anyway (as they should) - import local files from index where can to clean up - +- Switch to TS + -------- ## Vis -For an interactive plot - e.g. to use the §hover§ prop of marks - wrap the plot in a `` component. This provides a context which is used internally to set and access selected data. +For an interactive plot - e.g. to use the `hover` prop of marks - wrap the plot in a `` component. This provides a context which is used internally to set and access selected data. We can also wrap multiple plots in a single `Vis` component to coordinate interaction across the plots. For example, hovering on a point in one plot and highlight the corresponding point in another plot. It's also possible to include non-plot content in the `Vis`. In this case the `useVisSelection` and `useVisUpdateSelection` hooks can be used explicitly to get/set selected data. @@ -80,13 +76,13 @@ Notes: ### Ticks -There is an `XTick` component for creating and rendering the x ticks. Tick values can be passed to the `Plot` using the `xTick` prop. These are passed to `XTick`, `XGrid`, `XLabel` as the default of the `values` prop. +There is an `XTick` component for creating and rendering the x ticks. An array of tick values can be passed to a `Plot` (or `Frame`) using the `xTick` prop. If no values are passed, default values are automatically created from the x scale. -If the `xTick` prop of `Plot` is not used, the default values are automatically created from the x scale. +The `xTick` values from `Plot` are passed to `XTick`, `XGrid` and `XLabel` as the default `values` prop. Overwrite these values by using `values` explicitly. Alternatively, `values` can be a function - it is passed the `xTick` values from `Plot` and should return a new array of values. -> Note: The `values` prop of the `XTick`, `XGrid`, `XLabel` can be a function to transform the values passed down from the plot or frame - THIS IS LIKELY TO CHANGE!! +The `XLabel` component can take a `format` prop. This is a function that takes a tick value, its index, the array of tick values and the original array of tick values from `Plot`. The function should return the label value. -The behavior for y ticks is identical to that of x ticks. +The behavior described above is identical for the y dimension. ## Data @@ -220,7 +216,7 @@ POSSIBLE!!: - should `missing` be at plot and frame level rather than just mark level? - have e.g. a `constant` prop in marks so can avoid the hacky `data={[1]}` to draw a single mark when all props are constants - do not have same channel defaults for all marks? - annoying that need to set `strokeWidth` to see lines -- custom marks - e.g.custom shapes or images for points. +- just as have HTML mark, could have SVG mark, or even Plot mark to allow inlays - since data can be any iterable, should also allow tick values (when actually used since can be transformed by `values`) to be any iterable rather than just an array - end channels: front, facet (or row/column), ... - larger capture zone for hover/selection of mark diff --git a/packages/ui/src/components/Plot/components/marks/HTML.jsx b/packages/ui/src/components/Plot/components/marks/HTML.jsx new file mode 100644 index 000000000..a29438f4b --- /dev/null +++ b/packages/ui/src/components/Plot/components/marks/HTML.jsx @@ -0,0 +1,62 @@ +import Mark from "./Mark"; + +export default function HTML({ + data, + dataFrom, + missing = 'throw', + hover, + ...accessors + }) { + + const markChannels = [ + 'x', + 'y', + 'dx', + 'dy', + 'pxWidth', + 'pxHeight', + 'content', + 'anchor', // !! TO DO !! + 'pointerEvents', + ]; + + const tagName = 'foreignObject'; + + function createAttrs(row) { + const attrs = { + x: row.x + row.dx, // !! MODIFY X AND Y BASED ON ANCHOR !! + y: row.y + row.dy, + width: row.pxWidth, + height: row.pxHeight, + }; + if (row.pointerEvents) attrs.pointerEvents = row.pointerEvents; + return attrs; + } + + function createContent(row) { + return ( +
+ {row.content} +
+ ); + } + + return ; + +} + +// !! TO DO !! +// function xyToAnchor(x, y, width, height, anchor) { +// switch (anchor) { +// } +// } \ No newline at end of file diff --git a/packages/ui/src/components/Plot/components/util/DynamicTag.jsx b/packages/ui/src/components/Plot/components/util/DynamicTag.jsx index 3313d2e3d..5f3903399 100644 --- a/packages/ui/src/components/Plot/components/util/DynamicTag.jsx +++ b/packages/ui/src/components/Plot/components/util/DynamicTag.jsx @@ -1,4 +1,4 @@ export default function DynamicTag({ tagName, children, ...props }) { - const Tag = tagName; // capitalize to use it as a component - return {children}; - } \ No newline at end of file + const Tag = tagName; // capitalize to use it as a component + return {children}; +} \ No newline at end of file diff --git a/packages/ui/src/components/Plot/defaults/channelDefaults.js b/packages/ui/src/components/Plot/defaults/channelDefaults.js index 527415e1c..1edcef5fe 100644 --- a/packages/ui/src/components/Plot/defaults/channelDefaults.js +++ b/packages/ui/src/components/Plot/defaults/channelDefaults.js @@ -20,10 +20,10 @@ export const channelDefaults = { shape: 'circle', // Point area: 36, // Circle, Point cornerRadius: null, // HBar, VBar, Rect - width: 1, // VBar, Rect (x units) - height: 1, // HBar, Rect (y units) - pxWidth: 1, // VBar, Rect (pixels) - pxHeight: 1, // HBar, Rect (pixels) + width: 1, // VBar, Rect, (x units) + height: 1, // HBar, Rect, (y units) + pxWidth: 1, // VBar, Rect, HTML (pixels) + pxHeight: 1, // HBar, Rect, HTML (pixels) tension: 0.5, // Edge clockwise: true, // Edge path: null, // Path @@ -37,4 +37,6 @@ export const channelDefaults = { transformOrigin: 'center', // Text (CSS) transformBox: 'fill-box', // Text (CSS) transform: null, // Text (CSS) + content: null, // HTML + anchor: 'top-left' // HTML }; \ No newline at end of file diff --git a/packages/ui/src/components/Plot/index.js b/packages/ui/src/components/Plot/index.js index 872dc1de9..db2ff9b2f 100644 --- a/packages/ui/src/components/Plot/index.js +++ b/packages/ui/src/components/Plot/index.js @@ -17,4 +17,5 @@ export { default as YLabel } from "./components/YLabel"; export { default as YTick } from "./components/YTick"; export { default as Circle } from "./components/marks/Circle"; export { default as Segment } from "./components/marks/Segment"; -export { default as Text } from "./components/marks/Text"; \ No newline at end of file +export { default as Text } from "./components/marks/Text"; +export { default as HTML } from "./components/marks/HTML"; \ No newline at end of file From f73e4ba6721f39e04510241b0c85d6288a1fa300 Mon Sep 17 00:00:00 2001 From: Graham McNeill Date: Wed, 13 Nov 2024 10:21:02 +0000 Subject: [PATCH 15/16] tweak title --- packages/sections/src/study/GWASCredibleSets/Body.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/sections/src/study/GWASCredibleSets/Body.tsx b/packages/sections/src/study/GWASCredibleSets/Body.tsx index d91e87862..7f6261921 100644 --- a/packages/sections/src/study/GWASCredibleSets/Body.tsx +++ b/packages/sections/src/study/GWASCredibleSets/Body.tsx @@ -182,7 +182,7 @@ function pValue(row) { function ManhattanPlot({ loading, data }) { - const plotHeight = 380; + const plotHeight = 370; const theme = useTheme(); const background = theme.palette.background.paper; const markColor = theme.palette.primary.main; @@ -204,7 +204,7 @@ function ManhattanPlot({ loading, data }) { - + -log - 10 - (pValue) + 10 + (pValue) - {" "}of lead variants From 87ffdd7c716d59394e022cd97a63bf3785d56ba2 Mon Sep 17 00:00:00 2001 From: Graham McNeill Date: Wed, 13 Nov 2024 15:19:07 +0000 Subject: [PATCH 16/16] tooltip example --- .../src/study/GWASCredibleSets/Body.tsx | 134 +++++++++++------- .../components/Plot/components/marks/HTML.jsx | 31 ++-- 2 files changed, 101 insertions(+), 64 deletions(-) diff --git a/packages/sections/src/study/GWASCredibleSets/Body.tsx b/packages/sections/src/study/GWASCredibleSets/Body.tsx index f855b5ca8..4038cf1ea 100644 --- a/packages/sections/src/study/GWASCredibleSets/Body.tsx +++ b/packages/sections/src/study/GWASCredibleSets/Body.tsx @@ -7,6 +7,7 @@ import { DisplayVariantId, OtTable, Plot, + Vis, XAxis, YAxis, XTick, @@ -17,6 +18,7 @@ import { XGrid, Circle, Segment, + HTML, } from "ui"; import { naLabel } from "../../constants"; import { definition } from "."; @@ -202,61 +204,83 @@ function ManhattanPlot({ loading, data }) { }); return ( - - [0, ...tickData.map(chromo => chromo.end)]} - tickLength={15} - /> - - tickData.map(chromo => chromo.midpoint)} - format={(_, i, __, tickData) => tickData[i].chromosome} - padding={5} - /> - tickData.map(chromo => chromo.end)} - stroke="#cecece" - strokeDasharray="3 4" - /> - - -log - 10 - (pValue) - - - - - -Math.log10(v)} /> - genomePositions[d.variant.id]} - xx={d => genomePositions[d.variant.id]} - y={pValue} - yy={pValueMax} - stroke={markColor} - strokeWidth={1} - strokeOpacity={0.7} - /> - genomePositions[d.variant.id]} - y={pValue} - fill={background} - stroke={markColor} - strokeWidth={1.2} - area={circleArea} - /> - + + + [0, ...tickData.map(chromo => chromo.end)]} + tickLength={15} + /> + + tickData.map(chromo => chromo.midpoint)} + format={(_, i, __, tickData) => tickData[i].chromosome} + padding={5} + /> + tickData.map(chromo => chromo.end)} + stroke="#cecece" + strokeDasharray="3 4" + /> + + -log + 10 + (pValue) + + + + + -Math.log10(v)} /> + genomePositions[d.variant.id]} + xx={d => genomePositions[d.variant.id]} + y={pValue} + yy={pValueMax} + stroke={markColor} + strokeWidth={1} + strokeOpacity={0.7} + hover + /> + genomePositions[d.variant.id]} + y={pValue} + fill={background} + stroke={markColor} + strokeWidth={1.2} + area={circleArea} + hover + /> + + {/* TOOLTIP TEST */} + genomePositions[d.variant.id]} + y={pValue} + pxWidth={140} + pxHeight={50} + content={d => ( +
+ HTML tooltip +
{d.variant.id}
+
+ )} + // anchor="top-right" + dx={8} + dy = {-8} + /> +
+
); } diff --git a/packages/ui/src/components/Plot/components/marks/HTML.jsx b/packages/ui/src/components/Plot/components/marks/HTML.jsx index a29438f4b..c58cb5bf4 100644 --- a/packages/ui/src/components/Plot/components/marks/HTML.jsx +++ b/packages/ui/src/components/Plot/components/marks/HTML.jsx @@ -16,7 +16,7 @@ export default function HTML({ 'pxWidth', 'pxHeight', 'content', - 'anchor', // !! TO DO !! + 'anchor', 'pointerEvents', ]; @@ -24,18 +24,23 @@ export default function HTML({ function createAttrs(row) { const attrs = { - x: row.x + row.dx, // !! MODIFY X AND Y BASED ON ANCHOR !! - y: row.y + row.dy, width: row.pxWidth, height: row.pxHeight, }; + const [x, y] = + anchorPoint(row.x, row.y, row.pxWidth, row.pxHeight, row.anchor); + attrs.x = x + row.dx; + attrs.y = y + row.dy; if (row.pointerEvents) attrs.pointerEvents = row.pointerEvents; return attrs; } function createContent(row) { return ( -
+
{row.content}
); @@ -55,8 +60,16 @@ export default function HTML({ } -// !! TO DO !! -// function xyToAnchor(x, y, width, height, anchor) { -// switch (anchor) { -// } -// } \ No newline at end of file +function anchorPoint(x, y, w, h, anchor) { + switch (anchor) { + case 'middle': return [x - w / 2, y - h / 2]; + case 'top': return [x - w / 2, y]; + case 'top-right': return [x - w, y]; + case 'right': return [x - w, y]; + case 'bottom-right': return [x - w, y - h]; + case 'bottom': return [x - w / 2, y - h]; + case 'bottom-left': return [x, y - h]; + case 'left': return [x, y - h / 2]; + default: return [x, y]; // 'top-left' + } +} \ No newline at end of file