fable-compiler/Fable

d3js binding - current state

wrometr opened this issue · 10 comments

Hi awesome Fable community,

I would like to know how to use/create d3 binding with Fable/SAFE stack.

The old binding doesn't seem to exist any longer, beside that it was about v3 instead of latest v5. (d3js World Tour Map sample)

I have tried to create binding on my own with ts2fable but it is tricky as there is no single d3js type definition file ( d3js became very modular since previous versions)

I can also experiment with dynamic operator like d3?select or d3?geoPath but please kindly guide me how to tell Fable that it should connect it with yarn/npm package (let d3:obj = jsNative ? or something similar?)

Disclaimer it will be a long answer ;)

Hello @wrometr ,

After some trial and error, I manage to create a "basic" application using d3.js.

Showcase

The 3 dots are in black by default and turn orange using d3

2019-04-24 16 20 07

⚠️ The following code is working but it is probably not ready for production. And we can probably improve it, especially the unsafeJsThis which is like said unsafe⚠️

Code

module App

open Fable.Core
open Fable.Core.JsInterop

let d3 = D3.d3

// This code is ported from:
// https://code.likeagirl.io/how-to-set-up-d3-js-with-webpack-and-babel-7bd3f5e20df7
// It's used just to make sure basic d3.js is working

d3.selectAll("rect")
    .style("fill", "orange")
    |> ignore

[<Emit("this")>]
let unsafeJsThis : string = jsNative

// This code is ported from:
// http://christopheviau.com/d3_tutorial/
// Sections:
// - Simple example
// - Animation chaining

// I have no idea what the real generic types should be
// Perhaps we can improve the binding by removing them from the Type definition
// and only place them on the member signature
// NOTE: I am not sure if that's possible or no
let sampleSVG : D3.Selection.Selection<obj,obj,Browser.Types.HTMLElement,obj option> =
    d3.select("#hover-demo")
        .append("svg")
        .attr("width", 100)
        .attr("height", 100)

sampleSVG.append("circle")
        .style("stroke", "gray")
        .style("fill", "white")
        .attr("r", 40)
        .attr("cx", 50)
        .attr("cy", 50)
        .on("mouseover", fun () ->
            d3.select(unsafeJsThis) // Hack to mack the compiler happy, preferrable to find another way to do it
                .style("fill", "aliceblue")
            |> ignore
        )
        .on("mouseout", fun () ->
            d3.select(unsafeJsThis) // Hack to mack the compiler happy, preferrable to find another way to do it
                .style("fill", "white")
            |> ignore
        )
        |> ignore

// Animation demo

let animationSVG : D3.Selection.Selection<obj,obj,Browser.Types.HTMLElement,obj option> =
    d3.select("#animation-demo")
        .append("svg")
        .attr("width", 100)
        .attr("height", 100)

/// Please note that in order to have `d3.select(unsafeJsThis)` work
/// it's important to `inline` the function
let inline animateSecondStep () =
    d3.select(unsafeJsThis)
      .transition()
        .duration(1000.)
        .attr("r", 40)
    |> ignore

/// Please note that in order to have `d3.select(unsafeJsThis)` work
/// it's important to `inline` the function
let inline animateFirstStep () =
    d3.select(unsafeJsThis)
        .transition()
        .delay(0.)
        .duration(1000.)
        .attr("r", 10)
        .on("end", animateSecondStep)
    |> ignore

animationSVG.append("circle")
        .style("stroke", "gray")
        .style("fill", "white")
        .attr("r", 40)
        .attr("cx", 50)
        .attr("cy", 50)
        .on("mousedown", animateFirstStep)
        |> ignore

How did I do?

  1. I found the d3js API. So I knew which module I needed to export
  2. I found the d3js d.ts files in order to pass them to ts2fable.
    • You can find them by searchng for d3, d3-selection, ...
  3. Because I am using Fable.Core 3 I needed to create my own binding for Fable.Browser.SVG.
    • Please note that this step will be removed one day because we will release it as a library just like Fable.Browser.Dom for example :)
  4. I first converted d3-selection using ts2fable and fixed the compilation errors
  5. I also added this import: let [<Import("*","d3")>] d3 : Selection.IExports = jsNative.

Thanks to that, we can now call d3js by doing:

D3.d3.*

// Or even better

let d3 = D3.d3

d3.*

⚠️ From here it get a bit more complicated, as you said d3js is really modular but F# isn't. So here is the solution I found, please note that I search for it only 5-10 min and wanted to make the API works nothing more. So we can probably polish it or find a better way ^^ ⚠️

  1. I converted d3-transition using ts2fable.
  2. I ported the members coming from d3-transition and which was applied to D3_selection.Selection (type generated by ts2fable) back to Selection type directly. Source code
  3. I ported the rest of the d3-transition module at the bottom of D3.fs file. Source code

And "just" like that I was able to use d3js from Fable and almost just copy/paste the JavaScript code.

Important point

In order, to make d3js works for callback/event listener I used:

[<Emit("this")>]
let unsafeJsThis : string = jsNative

I really think we should have a better way to do it because I think Fable sometimes include this by itself. But I don't remember the rule.

I also needed to inline the callback function in order to capture the right this:

Source code

/// Please note that in order to have `d3.select(unsafeJsThis)` work
/// it's important to `inline` the function
let inline animateFirstStep () =
    d3.select(unsafeJsThis)
        .transition()
        .delay(0.)
        .duration(1000.)
        .attr("r", 10)
        .on("end", animateSecondStep)
   |> ignore

Can I play with it?

Sure 😄, I made a public repo. So in theory, you can just:

  1. Clone the repo
  2. Run npm install
  3. Run npm start

Instruction are in the README.

And it should work if I didn't mess up the repo :)

As a side note, if I were to use d3js I would probably try to wrap the API using function and/or class member in a way that facilitates function composition which is more idiomatic in F#.

@MangelMaxime There's a jsThis helper in Fable.Core.JsInterop but it basically does the same as your unsafeJsThis. Fable will translate all lambdas as "old" JS functions instead of arrow functions to prevent this changing its meaning in JS.

@alfonsogarciacaro Ok thank you, I knew I saw it somewhere but didn't find it at least now we know where it is :)

About the need to inline the functions like let inline animateSecondStep I guess it's due to how Fable generate functions. But I didn't really look deeply in this situation.

But if people want to use d3js they can investigate a bit more the situation I think :)

About the inline it seems is needed because the JS this cannot be in a nested function. You could solve this by using an inline helper that wraps the function and passes the this object as an argument.

let inline thisFunc (f: (obj->unit)) =
    fun () -> f jsThis

REPL sample using dynamic programming, because I know you love it ;)

You could also do it by creating an extension that automatically passes JS this to the callbacks:

// Here you would use the actual type from D3 bindings
type System.Object with
   member this.on(eventName: string, callback: obj->unit): obj =
        this?on(eventName, fun () -> callback jsThis)

REPL sample

During the weekend I spent some time on playing what you Fable Gods have proposed and ...it worked!

I was able to use several other d3 modules and as a result I can display districts map based on geojson data with nice DSL F# functions inside d3js. Example:

// d3js fill handler binding)
style("fill", fun feature-> 
    (model.District, feature) |> fill)
// and the fill itself
let fill   =
    function
    | District.Mine props -> "#F00"
    | District.Neighbor distance -> "#00F"
    | District.B1000A2000 distance -> green distance //interpolate from green chromatic scale
    | District.B2000A3000 distance -> red distance // interpolate from red chromatic scale
    | District.Distance distance -> "#999"
    | _ -> "#333"

this is a part of my F# Applied Challenge application so I will publish entire app at the end.

I sill need to figure out how to correctly set up d3js lifecycle in the Elmish architecture: d3js is all about generating views dynamically so binding events and Messages is tricky. For the time being I wanted things just working so I used the subscription like:

let districtClickSub initial =
    let sub dispatch =
        let click nr =
            let msg = DistrictChange nr
            msg |> dispatch
        Browser.window?districtClick <- click //this is invoked in event in d3js dynamic view

    Cmd.ofSub sub

Is there any known approach how to use dynamic views in Elmish? By dynamic i mean view that is generated with "behing js" on some message. In the update function where such a rendering is triggered there is no dispatch available that I can bind with js event callback

In general, when you have a third party library that need access to the DOM or to integrate at a special stage in the event bus you need to use Ref system from React.

It can be used with the Ref property on the Elmish views.

For example, in my production app I need to have access to Leaflet instance so I capture it like that:

RL.MapProps.Ref (fun x ->
    if not (isNull x) then
        mapRef <- unbox<Leaflet.Map> x?leafletElement)

Or you can use stateful components:

For example, manupulating the clipboard isn't easy in a browser because/thanks to security feature so I am using Clipboard library which need access to the DOM.

So I am using a stateful React component (written in JS but you can do it in F# too ;) )

import React from 'react';
import PropTypes from 'prop-types';

class CopyButton extends React.Component {
    constructor(props) {
        super(props);
        this.state = { showSuccess: false };
    }

    componentDidMount() {
        const Clipboard = require('clipboard');
        this.clipboard = new Clipboard(this.element);

        const self = this;
        this.clipboard.on('success', function (e) {
            self.setState({ showSuccess: true });
        });
    }

    onMouseLeave = () => {
        if (this.state.showSuccess) {
            this.setState({ showSuccess: false });
        }
    }

    render() {
        return (
            <div className={`button is-text ${this.state.showSuccess ? 'tooltip' : ''}`}
                data-tooltip="Le lien a été copié"
                ref={(element) => { this.element = element; }}
                data-clipboard-text={this.props.value}
                onMouseLeave={this.onMouseLeave}>
                <span className="icon">
                    <i className="fa fa-copy"></i>
                </span>
                <span>Cliquer ici pour obtenir le lien d'activation</span>
            </div>
        );
    }
}

CopyButton.propTypes = {
    value: PropTypes.string
};

CopyButton.defaultProps = {
    value: ""
};

export default CopyButton;

And then import my component in my F# code:

module CopyButton

open Fable.Core
open Fable.Core.JsInterop
open Fable.React

type Props =
    | Value of string

let copyButtton (props: Props list) : ReactElement =
    ofImport "default" "./../js/CopyButton.js" (keyValueList CaseRules.LowerFirst props) [ ]

I suppose you can use Ref using "function component" and hooks too some info about this type of component here. But I didn't experiment with it yet.

I forgot the "simple" event solution.

There is also the solution to use custom event.

  1. You listen to the custom event in your Elmish app
  2. You trigger the custom event from the d3js callback

or vice-versa.

However, this one is a bit tricky in general when using HMR because the event listeners are still on the old Elmish application and so you need to refresh the browser in order to have the correct behaviour. The explication are kind of complex and because I am not sure it's what you are looking for here I will say no more.

I hope this helped. I'll close the issue for now as it's not actionable here. If someone is interested please create a repo for D3.js bindings and we can continue the discussion and help there.

Cheers.

Sorry to wake this up, but Fable has changed such that an inline function no longer generates function () { .. } lambdas. My solution is to redefine thisFunc as

[<Emit("function () {  $0(this); }")>]
let inline thisFunc (f: (obj->unit)) = jsNative

Updated REPL

Updated fork of fable-d3js-demo