daybrush / ruler

A Ruler component that can draw grids and scroll infinitely.
https://daybrush.com/ruler/
MIT License
333 stars 39 forks source link

Support for Non-Pixel Units #2

Open joncursi opened 4 years ago

joncursi commented 4 years ago

I would like my rulers to use a different set of units than pixels. Some examples would be:

It would be great if there was a prop that allowed me to specify a pixel --> custom unit conversion. (E.g. 1 px = 1/72 inch)

daybrush commented 4 years ago

@joncursi

use zoom

96px = 1in 37.79px = 1cm

new Ruler(??, {
    zoom: 96,
    unit: 1,
});
smandepudi commented 4 years ago

This did not work for me. can you please be specific what i can do to fix. I needed to use this component with inches so I used the following in my html <ngx-guides #guides1 type="horizontal" unit="1"> and zoom in my typescript file

this.guides1.zoom = 96;

tom2strobl commented 2 years ago

@joncursi since you have access to unit and textFormat, you can simply use pixels as the measurement in your code, but calculate a metric or imperial value to show the user like textFormat: (px: number) => pixelToUnit(px, measuringUnits) (where pixelToUnit is just some math you have to do).

The only issue we are running into while doing this is javascript precision. So if eg. you say you want the ruler to show you 0mm, 25mm, 50mm etc. the unit prop of the ruler could be "200 pixels divided by dpi times one inch in mm": 200 / 72 * 25.4, then javascript number precision gets you to 70.55555555555554, which, since textFormat returns a number its unfortunately fairly imprecise (eg. returning 141.11111111111111 instead of 140ish), leaving the ruler sections at 0, 25, 50, 99, 124 etc., meaning the higher the number, the more unprecise the label. We're using Decimal instead of number to help with that, but since unit needs to be a number and textFormat returns a number we have no control over the final imprecision.

Bildschirmfoto 2022-02-02 um 16 57 11

@daybrush ideally the calculations in Ruler.tsx use precise Decimal types of your choice instead of numbers (we like decimal.js since it has no dependencies, it also has a smaller brother big.js that is only 6kb) to yield precise results. Are you willing to take on a dependency for precision support? Also, if understandably you don't have time for this – I could do a PR if that would be ok for you? If so let me know your thoughts regarding the new dependency.

tom2strobl commented 2 years ago

So for others running into the same issue. A pretty hacky, but actually for reasonable values kinda working solution is to round the final value to the desired steps again, note that you likely want a dynamic steps value depending on your zoom, but eg. on the zoom level shown above you'll note that our desired values are all multiples of 5, so we can snap to that using Math.round(value / 5) * 5. It's hacky since obviously at some large value the imprecision is bigger than the snap threshold, but with the values in our case that would only start at unreasonable viewer pan widths. And since you can specify a max range we can actually ensure people don't run into those values.

hitaloramon commented 1 year ago

@tom2strobl would you like to make your example of the image above available in your repository or codepen?

daybrush commented 1 year ago

@hitaloramon

Do you want this codepen?

https://codepen.io/daybrush/pen/PoyeNqE

hitaloramon commented 1 year ago

@daybrush

That wouldn't be good. I'm working with mm and when scrolling or zooming the page, the rulers don't keep the measures synchronized. I saw that @tom2strobl apparently got around this and wanted to see how he did it!

tom2strobl commented 1 year ago

@hitaloramon sorry that notification slipped through the gutter. I can't share the repo and am frankly too lazy to put together a full sample codepen—but it's along the lines of this:


// arbitrary helper function that does more than you need and lacks other helper functions but I just pasted it in here, you'll figure it out
const pixelToUnit = (
  pixel: number | Decimal,
  unit: MeasuringUnit,
  withUnit = true,
  returnAsReactNode = true,
  rounding = 0,
  fractionsForInches = true
) => {
  let pixelNumber = pixel as number
  if (typeof pixel !== 'number') {
    pixelNumber = pixel.toNumber()
  }
  if (unit === MeasuringUnit.Pixel) {
    return withUnit ? `${pixelNumber}${MeasuringUnitUnits[unit]}` : pixelNumber
  }
  if (unit === MeasuringUnit.Metric) {
    let value = pixelToMillimeter(pixel).toNumber()
    // due to javascript imprecision and the fact that 3rd party libs don't implement Decimal, although its hacky
    // we need to round in steps of 5 so the value eg. on the ruler is correct
    if (rounding) {
      value = Math.round(value / rounding) * rounding
    }
    return withUnit ? `${value}${MeasuringUnitUnits[unit]}` : value
  }
  if (unit === MeasuringUnit.Imperial) {
    const value = fractionsForInches
      ? floatToFractionString(pixelToInch(pixel).toNumber(), returnAsReactNode)
      : pixelToInch(pixel).toNumber()
    return withUnit ? `${value}${MeasuringUnitUnits[unit]}` : value
  }
  throw new Error(`Unknown measuring unit: ${unit}`)
}

// Base unit is dependent on the measuring unit
const BaseUnit = MeasuringUnitBase[measuringUnits]
// by leveraging unit steps and the current zoom, we show more detailed steps when zoomed in
const UnitSteps = BaseUnit.dividedBy(2).toNumber()

const unit = Math.round(BaseUnit.toNumber() / zoom / UnitSteps) * UnitSteps
const textFormat = useCallback(
  (px: number) => pixelToUnit(px, measuringUnits, true, false, 5) as string,
  [measuringUnits]
)
const dragPosFormat = useCallback(
  (px: number) => pixelToUnit(px, measuringUnits, true, false, 1) as string,
  [measuringUnits]
)
const baseGuidesProps = useMemo(
  () => ({
    // [...some more vars I dont list here]
    lockGuides,
    segment: measuringUnits === MeasuringUnit.Imperial ? 8 : 10,
    zoom,
    unit,
    snapThreshold: 5,
    textFormat,
    dragPosFormat,
  }),
  [dragPosFormat, lockGuides, measuringUnits, showRuler, textFormat, theme, unit, zoom]
)
// we take 1cm in inches and convert it to pixels with 300 dpi
const snapMargin = new Decimal(new Decimal(1).dividedBy(ONE_INCH_IN_MM.dividedBy(10))).times(defaultDpi).toNumber()
const horizontalSnaps = useMemo(
  () => [0 + snapMargin, pageFormatValues[1] - snapMargin].map(Math.round),
  [pageFormatValues, snapMargin]
)
const verticalSnaps = useMemo(
  () => [0 + snapMargin, pageFormatValues[0] - snapMargin].map(Math.round),
  [pageFormatValues, snapMargin]
)
const jsx = (
  <Guides
    // [...some more props I dont list here]
    {...baseGuidesProps}
    snaps={horizontalSnaps}
    type="horizontal"
  />
  <Guides
    // [...some more props I dont list here]
    {...baseGuidesProps}
    snaps={verticalSnaps}
    type="vertical"
  />
)