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
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?
- I found the d3js API. So I knew which module I needed to export
- I found the d3js d.ts files in order to pass them to ts2fable.
- You can find them by searchng for
d3
,d3-selection
, ...
- You can find them by searchng for
- Because I am using
Fable.Core 3
I needed to create my own binding forFable.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 :)
- Please note that this step will be removed one day because we will release it as a library just like
- I first converted
d3-selection
usingts2fable
and fixed the compilation errors - 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.*
- I converted
d3-transition
usingts2fable
. - I ported the members coming from
d3-transition
and which was applied toD3_selection.Selection
(type generated by ts2fable) back toSelection
type directly. Source code - I ported the rest of the
d3-transition
module at the bottom ofD3.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
:
/// 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:
- Clone the repo
- Run
npm install
- 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)
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.
- You listen to the custom event in your Elmish app
- 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