emilhe / dash-leaflet

MIT License
204 stars 33 forks source link

Contribution to cluster #245

Open pip-install-python opened 2 weeks ago

pip-install-python commented 2 weeks ago

Hey Emil,

Reaching out because I've found myself building a custom cluster for leaflet. Was able to get it working, wanted to share with you the code as you might be able add it as an option for the cluster as a prop or an improvement.

External script I used "https://cdnjs.cloudflare.com/ajax/libs/chroma-js/2.1.0/chroma.min.js"

This is the code I built to design a custom icon after zooming past the cluster or on cluster click:

point_to_layer_js = assign(
    """
function(feature, latlng){
    const iconData = feature.properties.icon_data;
    const flag = L.icon({
        iconUrl: iconData.url, 
        iconSize: [35, 35],
        tooltipAnchor: [20, 0]  // Adjusts the tooltip 10px to the right
    });
    const marker = L.marker(latlng, {icon: flag});
    marker.bindTooltip('<img src="' + feature.properties.image + '" style="width: 30vw; height: 20vh; max-width: 250px; max-height: 250px;">');
    marker.on('click', function(e) {
        // Create a new custom event
        var event = new CustomEvent('marker_click', {detail: feature.properties.pk});
        // Dispatch the event
        window.dispatchEvent(event);
    });
    return marker;
}
"""
)

This is the custom cluster I've built:

m_type_colors = {
    "restaurants": "#FF6347",     # Tomato
    "fishing": "#1E90FF",         # DodgerBlue
    "digital store": "#FFD700",   # Gold
    "parks": "#32CD32",           # LimeGreen
    "bait": "#FF8C00",            # DarkOrange
    "seafood market": "#00BFFF",  # DeepSkyBlue
    "vacation_rental": "#FF69B4", # HotPink
    "rv hookups": "#8A2BE2",      # BlueViolet
    "events": "#FF4500",          # OrangeRed
    "stores": "#ADFF2F",          # GreenYellow
    "nonprofit": "#FF1493",        # DeepPink
    "night_life": '#222429',
}

cluster_to_layer = assign(
    """function(feature, latlng, index, context){
    function ringSVG(opt) {
        function describeArc(opt) {
            const innerStart = polarToCartesian(opt.x, opt.y, opt.radius, opt.endAngle);
            const innerEnd = polarToCartesian(opt.x, opt.y, opt.radius, opt.startAngle);
            const outerStart = polarToCartesian(opt.x, opt.y, opt.radius + opt.ringThickness, opt.endAngle);
            const outerEnd = polarToCartesian(opt.x, opt.y, opt.radius + opt.ringThickness, opt.startAngle);
            const largeArcFlag = opt.endAngle - opt.startAngle <= 180 ? "0" : "1";
            return [ "M", outerStart.x, outerStart.y,
                     "A", opt.radius + opt.ringThickness, opt.radius + opt.ringThickness, 0, largeArcFlag, 0, outerEnd.x, outerEnd.y,
                     "L", innerEnd.x, innerEnd.y,
                     "A", opt.radius, opt.radius, 0, largeArcFlag, 1, innerStart.x, innerStart.y,
                     "L", outerStart.x, outerStart.y, "Z"].join(" ");
        }

        const polarToCartesian = (centerX, centerY, radius, angleInDegrees) => {
            return { x: centerX + (radius * Math.cos((angleInDegrees - 90) * Math.PI / 180.0)),
                     y: centerY + (radius * Math.sin((angleInDegrees - 90) * Math.PI / 180.0)) };
        }

        opt = opt || {};
        const defaults = { width: 60, height: 60, radius: 20, gapDeg: 5, fontSize: 17, text: `test`,
                           ringThickness: 7, colors: [] };
        opt = {...defaults, ...opt};

        let startAngle = 90;
        let paths = '';
        const totalPerc = opt.colors.reduce((acc, val) => acc + val.perc, 0);
        for (let i = 0; i < opt.colors.length; i++) {
            const segmentPerc = opt.colors[i].perc / totalPerc;
            const endAngle = startAngle + (segmentPerc * 360) - opt.gapDeg;
            const d = describeArc({ x: opt.width / 2, y: opt.height / 2, radius: opt.radius, ringThickness: opt.ringThickness, startAngle, endAngle });
            paths += `<path fill="${opt.colors[i].color}" d="${d}"></path>`;
            startAngle = endAngle + opt.gapDeg;
        }

        console.log("SVG Paths: ", paths);

        return `<svg width="${opt.width}" height="${opt.height}">
                    ${paths}
                    <text x="50%" y="50%" alignment-baseline="middle" text-anchor="middle" font-size="${opt.fontSize}"
                          fill="black">${opt.text || opt.goodPerc}
                    </text>
                </svg>`;
    }

    const leaves = index.getLeaves(feature.properties.cluster_id);
    const m_types = leaves.map(leaf => leaf.properties.type);

    // Count the occurrences of each m_type
    const m_type_counts = m_types.reduce((acc, m_type) => {
        acc[m_type] = (acc[m_type] || 0) + 1;
        return acc;
    }, {});

    // Calculate the percentage for each m_type
    const colors = Object.keys(m_type_counts).map(m_type => ({
        color: context.hideout.m_type_colors[m_type] || 'gray',
        perc: m_type_counts[m_type]
    }));

    const scatterIcon = L.DivIcon.extend({
        createIcon: function(oldIcon) {
               let icon = L.DivIcon.prototype.createIcon.call(this, oldIcon);
               return icon;
        }
    });

    const total = feature.properties.point_count_abbreviated;

    const icon = new scatterIcon({
        html: ringSVG({
                text: `${total}`,
                colors
            }),
        className: "marker-cluster",
        iconSize: L.point(40, 40)
    });
    return L.marker(latlng, {icon: icon});
}
"""
)

You can look at this in action via https://dash.geomapindex.com and if you'd be interested I can provide you with an isolated working example with everything. Just know a lot of datasets have specific type or other ways of filter. This turns the cluster into a ringSVG which breaks up by how many types of a specific dataset fall within that category.

Not sure how exactly it would fit into the project if at all, but figure it was noteworthy and might be interesting / useful in some way.

Screenshot 2024-07-06 at 1 20 08 PM
emilhe commented 2 days ago

It is a nice visualization that you have arrived at. I am not sure that it would fit into the library directly though. It seem more like an example of an advanced use case to me, i.e. it would probably fit more natually as documentation (similar to the tutorial section in the docs)